@srfnstack/fntags 1.2.7 → 1.3.0

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.3.0",
4
4
  "author": "Robert Kempton <r@snow87.com>",
5
5
  "private": false,
6
6
  "homepage": "https://github.com/srfnstack/fntags",
package/src/fnroute.d.mts CHANGED
@@ -40,13 +40,25 @@ export function routeSwitch(...children: (any | Node)[]): Node | (() => Node);
40
40
  */
41
41
  export function fnlink(...children: (any | Node)[]): HTMLAnchorElement;
42
42
  /**
43
- * A function to navigate to the specified route
43
+ * A function to navigate to the specified route.
44
+ *
45
+ * Navigating to the current path with only a different hash or query (for example from /docs to /docs#faq)
46
+ * and no new context is not a route change: the history entry is updated and the hash target is scrolled
47
+ * into view, but the route is not re-rendered and no route events are emitted.
44
48
  * @param {string} route The route to navigate to
45
- * @param {any} context Data related to the route change
49
+ * @param {any} [context] Data related to the route change
46
50
  * @param {boolean} replace Whether to replace the state or push it. pushState is used by default.
47
51
  * @param {boolean} silent Prevent route change events from being emitted for this route change
48
52
  */
49
53
  export function goTo(route: string, context?: any, replace?: boolean, silent?: boolean): void;
54
+ /**
55
+ * Scroll the element whose id matches the hash into view. If the element does not exist yet, for example because
56
+ * the route's content is a promise that has not resolved, wait for it to be added to the document and scroll then.
57
+ * With no hash, scroll to the top of the page.
58
+ * @param {string|null|undefined} hash The hash to scroll to, with or without the leading #
59
+ * @param {number} [timeout] How long to wait for the element to appear, in milliseconds
60
+ */
61
+ export function scrollToHash(hash: string | null | undefined, timeout?: number): void;
50
62
  /**
51
63
  * Listen for routing events
52
64
  * @param {string} event a string event to listen for
@@ -1 +1 @@
1
- {"version":3,"file":"fnroute.d.mts","sourceRoot":"","sources":["fnroute.mjs"],"names":[],"mappings":"AAMA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,mCAHW,CAAC,MAAO,IAAI,CAAC,EAAE,GACb,cAAc,CAoB1B;AAED;;;;;GAKG;AACH,yCAHW,CAAC,MAAO,IAAI,CAAC,EAAE,GACb,IAAI,GAAC,CAAC,MAAI,IAAI,CAAC,CAyB3B;AAoBD;;;;GAIG;AACH,oCAHW,CAAC,MAAO,IAAI,CAAC,EAAE,GACb,iBAAiB,CAuB7B;AAED;;;;;;GAMG;AACH,4BALW,MAAM,YACN,GAAG,YACH,OAAO,WACP,OAAO,QA4CjB;AAiED;;;;;;GAMG;AACH,iCALW,MAAM,iBAGL,MAAI,IAAI,CAanB;AAED;;;GAGG;AACH,sCAFW,MAAM,QAOhB;AAxFD;;;GAGG;AAEH;;;GAGG;AACH,6BAFU,OAAO,cAAc,EAAE,OAAO,CAAC,cAAc,CAAC,CAEf;AAEzC;;;GAGG;AAEH;;;GAGG;AACH,wBAFU,OAAO,cAAc,EAAE,OAAO,CAAC,SAAS,CAAC,CAO/C;AAMJ;;GAEG;AACH;;;GAGG;AACH,gCAFU,UAAU,CAEgC;AACpD;;;GAGG;AACH,+BAFU,UAAU,CAE8B;AAClD;;;GAGG;AACH,kCAFU,UAAU,CAEoC;;;;;;;;wBAnC3C;IAAC,WAAW,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,GAAG,CAAA;CAAC;yBAmBrD,MAAM"}
1
+ {"version":3,"file":"fnroute.d.mts","sourceRoot":"","sources":["fnroute.mjs"],"names":[],"mappings":"AAMA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,mCAHW,CAAC,MAAO,IAAI,CAAC,EAAE,GACb,cAAc,CAoB1B;AAED;;;;;GAKG;AACH,yCAHW,CAAC,MAAO,IAAI,CAAC,EAAE,GACb,IAAI,GAAC,CAAC,MAAI,IAAI,CAAC,CAyB3B;AAoBD;;;;GAIG;AACH,oCAHW,CAAC,MAAO,IAAI,CAAC,EAAE,GACb,iBAAiB,CAuB7B;AAED;;;;;;;;;;GAUG;AACH,4BALW,MAAM,YACN,GAAG,YACH,OAAO,WACP,OAAO,QA+CjB;AAID;;;;;;GAMG;AACH,mCAHW,MAAM,GAAC,IAAI,GAAC,SAAS,YACrB,MAAM,QAiChB;AAiED;;;;;;GAMG;AACH,iCALW,MAAM,iBAGL,MAAI,IAAI,CAanB;AAED;;;GAGG;AACH,sCAFW,MAAM,QAOhB;AAxFD;;;GAGG;AAEH;;;GAGG;AACH,6BAFU,OAAO,cAAc,EAAE,OAAO,CAAC,cAAc,CAAC,CAEf;AAEzC;;;GAGG;AAEH;;;GAGG;AACH,wBAFU,OAAO,cAAc,EAAE,OAAO,CAAC,SAAS,CAAC,CAO/C;AAMJ;;GAEG;AACH;;;GAGG;AACH,gCAFU,UAAU,CAEgC;AACpD;;;GAGG;AACH,+BAFU,UAAU,CAE8B;AAClD;;;GAGG;AACH,kCAFU,UAAU,CAEoC;;;;;;;;wBAnC3C;IAAC,WAAW,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,GAAG,CAAA;CAAC;yBAmBrD,MAAM"}
package/src/fnroute.mjs CHANGED
@@ -106,7 +106,7 @@ function updatePathParameters () {
106
106
  * @returns {HTMLAnchorElement} An anchor element that will navigate to the specified route when clicked
107
107
  */
108
108
  export function fnlink (...children) {
109
- let context = null
109
+ let context
110
110
  if (children[0] && children[0].context) {
111
111
  context = children[0].context
112
112
  }
@@ -129,21 +129,36 @@ export function fnlink (...children) {
129
129
  }
130
130
 
131
131
  /**
132
- * A function to navigate to the specified route
132
+ * A function to navigate to the specified route.
133
+ *
134
+ * Navigating to the current path with only a different hash or query (for example from /docs to /docs#faq)
135
+ * and no new context is not a route change: the history entry is updated and the hash target is scrolled
136
+ * into view, but the route is not re-rendered and no route events are emitted.
133
137
  * @param {string} route The route to navigate to
134
- * @param {any} context Data related to the route change
138
+ * @param {any} [context] Data related to the route change
135
139
  * @param {boolean} replace Whether to replace the state or push it. pushState is used by default.
136
140
  * @param {boolean} silent Prevent route change events from being emitted for this route change
137
141
  */
138
- export function goTo (route, context = {}, replace = false, silent = false) {
142
+ export function goTo (route, context, replace = false, silent = false) {
139
143
  const newPath = window.location.origin + makePath(route)
144
+ const hash = newPath.indexOf('#') > -1 ? newPath.split('#')[1] : null
145
+ const oldPathState = pathState()
146
+ const currentPath = ensureOnlyLeadingSlash(route.split(/[#?]/)[0])
140
147
 
141
- const patch = {
142
- currentPath: route.split(/[#?]/)[0],
143
- context
148
+ if (currentPath === oldPathState.currentPath && context === undefined) {
149
+ if (replace) {
150
+ history.replaceState({}, route, newPath)
151
+ } else {
152
+ history.pushState({}, route, newPath)
153
+ }
154
+ scrollToHash(hash)
155
+ return
144
156
  }
145
157
 
146
- const oldPathState = pathState()
158
+ const patch = {
159
+ currentPath,
160
+ context: context === undefined ? {} : context
161
+ }
147
162
  const newPathState = Object.assign({}, oldPathState, patch)
148
163
  if (!silent) {
149
164
  try {
@@ -160,25 +175,59 @@ export function goTo (route, context = {}, replace = false, silent = false) {
160
175
  }
161
176
 
162
177
  setTimeout(() => {
163
- pathState.assign({
164
- currentPath: route.split(/[#?]/)[0],
165
- context
166
- })
178
+ pathState.assign(patch)
167
179
  if (!silent) {
168
180
  emit(afterRouteChange, newPathState, oldPathState)
169
181
  }
170
- if (newPath.indexOf('#') > -1) {
171
- const el = document.getElementById(decodeURIComponent(newPath.split('#')[1]))
172
- el && el.scrollIntoView()
173
- } else {
174
- window.scrollTo(0, 0)
175
- }
182
+ scrollToHash(hash)
176
183
  if (!silent) {
177
184
  emit(routeChangeComplete, newPathState, oldPathState)
178
185
  }
179
186
  })
180
187
  }
181
188
 
189
+ let cancelPendingScroll = null
190
+
191
+ /**
192
+ * Scroll the element whose id matches the hash into view. If the element does not exist yet, for example because
193
+ * the route's content is a promise that has not resolved, wait for it to be added to the document and scroll then.
194
+ * With no hash, scroll to the top of the page.
195
+ * @param {string|null|undefined} hash The hash to scroll to, with or without the leading #
196
+ * @param {number} [timeout] How long to wait for the element to appear, in milliseconds
197
+ */
198
+ export function scrollToHash (hash, timeout = 5000) {
199
+ if (cancelPendingScroll) {
200
+ cancelPendingScroll()
201
+ }
202
+ const id = hash ? decodeURIComponent(hash.startsWith('#') ? hash.slice(1) : hash) : ''
203
+ if (!id) {
204
+ window.scrollTo(0, 0)
205
+ return
206
+ }
207
+ const scrollIfPresent = () => {
208
+ const el = document.getElementById(id)
209
+ if (el) {
210
+ el.scrollIntoView()
211
+ }
212
+ return !!el
213
+ }
214
+ if (scrollIfPresent() || typeof MutationObserver === 'undefined') {
215
+ return
216
+ }
217
+ const observer = new MutationObserver(() => {
218
+ if (scrollIfPresent()) {
219
+ cancelPendingScroll()
220
+ }
221
+ })
222
+ const timer = setTimeout(() => cancelPendingScroll(), timeout)
223
+ cancelPendingScroll = () => {
224
+ observer.disconnect()
225
+ clearTimeout(timer)
226
+ cancelPendingScroll = null
227
+ }
228
+ observer.observe(document.documentElement, { childList: true, subtree: true })
229
+ }
230
+
182
231
  const ensureOnlyLeadingSlash = (part) => removeTrailingSlash(part.startsWith('/') ? part : '/' + part)
183
232
 
184
233
  const removeTrailingSlash = part => part.endsWith('/') && part.length > 1 ? part.slice(0, -1) : part
@@ -280,6 +329,12 @@ window.addEventListener(
280
329
  const patch = {
281
330
  currentPath: ensureOnlyLeadingSlash(window.location.pathname.replace(new RegExp('^' + pathState().rootPath), '')) || '/'
282
331
  }
332
+ // Clicking <a href="#section"> and moving back/forward between hash entries fire popstate without
333
+ // changing the path. That is not a route change, so leave the rendered route alone and let the
334
+ // browser handle scrolling to the fragment.
335
+ if (patch.currentPath === oldPathState.currentPath) {
336
+ return
337
+ }
283
338
  const newPathState = Object.assign({}, oldPathState, patch)
284
339
  try {
285
340
  emit(beforeRouteChange, newPathState, oldPathState)
@@ -294,6 +349,12 @@ window.addEventListener(
294
349
  }
295
350
  )
296
351
 
352
+ // On initial load the browser only scrolls to the hash if the target already exists. When the target is inside
353
+ // a route whose content has not rendered yet, wait for it the same way goTo does.
354
+ if (window.location.hash && !document.getElementById(decodeURIComponent(window.location.hash.slice(1)))) {
355
+ scrollToHash(window.location.hash)
356
+ }
357
+
297
358
  const makePath = path => (pathState().rootPath === '/' ? '' : pathState().rootPath) + ensureOnlyLeadingSlash(path)
298
359
 
299
360
  const makePathPattern = (path, absolute) => {
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
@@ -63,7 +72,8 @@ export function h<T extends HTMLElement | SVGElement>(tag: string, ...children:
63
72
  */
64
73
  export function fnstate<T>(initialValue: T, mapKey?: (T: any) => any): FnState<T>;
65
74
  /**
66
- * Convert non objects (objects are assumed to be nodes) to text nodes and allow promises to resolve to nodes
75
+ * Convert non objects (objects are assumed to be nodes) to text nodes and allow promises to resolve to nodes.
76
+ * Arrays are flattened at any depth into a DocumentFragment.
67
77
  * @param {any} node The node to render
68
78
  * @returns {Node} The rendered node
69
79
  */
@@ -147,6 +157,7 @@ export type FnStateObj<T> = {
147
157
  /**
148
158
  * Bind this state to the given element function. This causes the element to be replaced when the state changes.
149
159
  * If called with no parameters, the state's value will be rendered as an element.
160
+ * 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
161
  */
151
162
  bindAs: (element?: (newValue: T, oldValue: T) => (Node | any)) => Node;
152
163
  /**
@@ -161,7 +172,8 @@ export type FnStateObj<T> = {
161
172
  */
162
173
  bindProp: (prop: string) => Node;
163
174
  /**
164
- * Bind attribute values to state changes
175
+ * Bind attribute values to state changes.
176
+ * 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
177
  */
166
178
  bindAttr: (attribute?: (newValue: T, oldValue: T) => (string | any)) => any;
167
179
  /**
@@ -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,OAsC1B;AAUD;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AAEH;;;GAGG;AAEH;;;;;;;;;;GAUG;AACH,iEAPgB,GAAG,cAqIlB;AAwlBD;;;;;GAKG;AACH,iCAHW,GAAG,GACD,IAAI,CAgChB;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;;;;;;;;;;kCAzjCmC,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()
@@ -65,6 +81,8 @@ export function h (tag, ...children) {
65
81
  for (let i = firstChildIdx; i < children.length; i++) {
66
82
  const child = children[i]
67
83
  if (Array.isArray(child)) {
84
+ // Append array items directly to skip the DocumentFragment renderNode would build.
85
+ // Nested arrays inside still go through renderNode, which flattens them.
68
86
  for (const c of child) {
69
87
  element.append(renderNode(c))
70
88
  }
@@ -88,12 +106,14 @@ function hasNs (val) {
88
106
  * @typedef FnStateObj A container for a state value that can be bound to.
89
107
  * @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
108
  * If called with no parameters, the state's value will be rendered as an element.
109
+ * 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
110
  * @property {(parent: (()=>(Node|any))|any|Node, element: (childState: FnState)=>(Node|any))=>Node} bindChildren Bind the values of this state to the given element.
92
111
  * Values are items/elements of an array.
93
112
  * If the current value is not an array, this will behave the same as bindAs.
94
113
  * @property {(prop: string)=>Node} bindProp Bind to a property of an object stored in this state instead of the state itself.
95
114
  * Shortcut for `mystate.bindAs((current)=> current[prop])`
96
- * @property {(attribute?: (newValue: T, oldValue: T)=>(string|any))=>any} bindAttr Bind attribute values to state changes
115
+ * @property {(attribute?: (newValue: T, oldValue: T)=>(string|any))=>any} bindAttr Bind attribute values to state changes.
116
+ * 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
117
  * @property {(style?: (newValue: T, oldValue: T)=>string) => string} bindStyle Bind style values to state changes
98
118
  * @property {(element?: (selectedKey: any)=>(Node|any))=>Node} bindSelect Bind selected state to an element
99
119
  * @property {(attribute?: (selectedKey: any)=>(string|any))=>any} bindSelectAttr Bind selected state to an attribute
@@ -428,7 +448,9 @@ function doBindChildren (parent, element) {
428
448
  throw new Error('You must pass a function to produce child elements.')
429
449
  }
430
450
  if (typeof ctx.mapKey !== 'function') {
431
- console.warn('Using value index as key, may not work correctly when moving items...')
451
+ if (devMode) {
452
+ 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).')
453
+ }
432
454
  ctx.mapKey = (_, i) => i
433
455
  }
434
456
 
@@ -459,7 +481,9 @@ function doBindChildren (parent, element) {
459
481
 
460
482
  this.subscribe((_, oldState) => {
461
483
  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.')
484
+ if (devMode) {
485
+ 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.')
486
+ }
463
487
  new Promise((resolve) => {
464
488
  this([ctx.currentValue])
465
489
  resolve()
@@ -478,6 +502,15 @@ function doBindChildren (parent, element) {
478
502
  return parent
479
503
  }
480
504
 
505
+ // A bound function must produce exactly one node. A DocumentFragment empties
506
+ // itself when inserted, so there would be nothing left to replaceWith on the
507
+ // next state change and the binding would silently stop updating.
508
+ function assertSingleNode (evaluated, name) {
509
+ if (Array.isArray(evaluated)) {
510
+ throw new Error(name + ' functions must return a single node, not an array. Wrap the array in an element, e.g. div(...items).')
511
+ }
512
+ }
513
+
481
514
  const doBind = function (ctx, element, handleReplace) {
482
515
  if (typeof element !== 'function') {
483
516
  throw new Error('You must pass a function to bind with')
@@ -490,6 +523,7 @@ const doBind = function (ctx, element, handleReplace) {
490
523
  const thisRenderCleanups = []
491
524
  activeRenderCleanups = thisRenderCleanups
492
525
  const evaluated = evaluateElement(element, ctx.currentValue)
526
+ assertSingleNode(evaluated, 'bindAs')
493
527
  const childElCtx = (typeof evaluated === 'function' && evaluated.isBindGetter)
494
528
  ? evaluated.elCtx
495
529
  : null
@@ -556,6 +590,28 @@ function propagateReplace (elCtx, oldNode, newNode) {
556
590
  }
557
591
  }
558
592
 
593
+ // Emit a dev-mode warning when a bindAs replacement would destroy a subtree
594
+ // containing the focused element. This is the most common fntags footgun:
595
+ // wrapping an <input> in bindAs replaces the element on every keystroke, so
596
+ // the user loses focus and caret position mid-edit. The fix is almost always
597
+ // to bind the `value` attribute with bindAttr instead. We only check once per
598
+ // replacement and only touch the DOM when an element is actually focused.
599
+ function warnIfReplacingFocused (oldNode) {
600
+ if (typeof document === 'undefined') return
601
+ const active = document.activeElement
602
+ if (!active || active === document.body) return
603
+ if (oldNode && oldNode.nodeType === 1 && (oldNode === active || oldNode.contains(active))) {
604
+ console.warn(
605
+ 'bindAs is about to replace a subtree containing the focused element <' +
606
+ active.tagName.toLowerCase() + '>. The replacement will steal focus and ' +
607
+ 'reset caret/selection. For form inputs, bind the `value` attribute with ' +
608
+ 'bindAttr instead of wrapping the input in bindAs. ' +
609
+ 'Call setDevMode(false) to silence this warning.',
610
+ active
611
+ )
612
+ }
613
+ }
614
+
559
615
  const updateReplacer = (ctx, element, elCtx) => (_, oldValue) => {
560
616
  // Clean up subscriptions from the previous render before re-rendering.
561
617
  // This prevents nested bindAs/bindAttr/bindStyle subscriptions from
@@ -567,6 +623,7 @@ const updateReplacer = (ctx, element, elCtx) => (_, oldValue) => {
567
623
  const thisRenderCleanups = []
568
624
  activeRenderCleanups = thisRenderCleanups
569
625
  const evaluated = evaluateElement(element, ctx.currentValue, oldValue)
626
+ assertSingleNode(evaluated, 'bindAs')
570
627
  const childElCtx = (typeof evaluated === 'function' && evaluated.isBindGetter)
571
628
  ? evaluated.elCtx
572
629
  : null
@@ -595,6 +652,7 @@ const updateReplacer = (ctx, element, elCtx) => (_, oldValue) => {
595
652
  // is a no-op in the DOM but wastes a microtask.
596
653
  if (rendered !== elCtx.current) {
597
654
  const oldCurrent = elCtx.current
655
+ if (devMode) warnIfReplacingFocused(oldCurrent)
598
656
  elCtx.current.replaceWith(rendered)
599
657
  elCtx.current = rendered
600
658
  // Keep any ancestor bindAs whose elCtx shared this node in sync with
@@ -745,7 +803,9 @@ function arrangeElements (ctx, bindContext, oldState) {
745
803
  const thisElementCleanups = []
746
804
  activeRenderCleanups = thisElementCleanups
747
805
  // Pass the valueState (the fnstate wrapper) to the user's element builder
748
- current = bindContext.boundElementByKey[key] = renderNode(evaluateElement(bindContext.element, valueState))
806
+ const evaluated = evaluateElement(bindContext.element, valueState)
807
+ assertSingleNode(evaluated, 'bindChildren')
808
+ current = bindContext.boundElementByKey[key] = renderNode(evaluated)
749
809
  activeRenderCleanups = prevCleanups
750
810
  bindContext.elementCleanups[key] = thisElementCleanups
751
811
  current.key = key
@@ -814,7 +874,8 @@ const evaluateElement = (element, value, oldValue) => {
814
874
  }
815
875
 
816
876
  /**
817
- * Convert non objects (objects are assumed to be nodes) to text nodes and allow promises to resolve to nodes
877
+ * Convert non objects (objects are assumed to be nodes) to text nodes and allow promises to resolve to nodes.
878
+ * Arrays are flattened at any depth into a DocumentFragment.
818
879
  * @param {any} node The node to render
819
880
  * @returns {Node} The rendered node
820
881
  */
@@ -823,6 +884,15 @@ export function renderNode (node) {
823
884
  const element = h('div')
824
885
  node(element, 'node')
825
886
  return element
887
+ } else if (Array.isArray(node)) {
888
+ // Arrays (including nested arrays and arrays resolved from promises) are
889
+ // flattened into a DocumentFragment so each item lands in the DOM as its
890
+ // own node. Without this, append/replaceWith would stringify the array.
891
+ const frag = document.createDocumentFragment()
892
+ for (const child of node) {
893
+ frag.append(renderNode(child))
894
+ }
895
+ return frag
826
896
  } else if (node && typeof node === 'object') {
827
897
  if (typeof node.then === 'function') {
828
898
  let temp = h('div', { style: 'display:none', class: 'fntags-promise-marker' })