@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 +1 -1
- package/src/fnroute.d.mts +14 -2
- package/src/fnroute.d.mts.map +1 -1
- package/src/fnroute.mjs +79 -18
- package/src/fntags.d.mts +15 -3
- package/src/fntags.d.mts.map +1 -1
- package/src/fntags.mjs +75 -5
package/package.json
CHANGED
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
|
package/src/fnroute.d.mts.map
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
|
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
|
-
|
|
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
|
/**
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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' })
|