@srfnstack/fntags 1.2.8 → 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.8",
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
@@ -72,7 +72,8 @@ export function h<T extends HTMLElement | SVGElement>(tag: string, ...children:
72
72
  */
73
73
  export function fnstate<T>(initialValue: T, mapKey?: (T: any) => any): FnState<T>;
74
74
  /**
75
- * 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.
76
77
  * @param {any} node The node to render
77
78
  * @returns {Node} The rendered node
78
79
  */
@@ -1 +1 @@
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"}
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
@@ -81,6 +81,8 @@ export function h (tag, ...children) {
81
81
  for (let i = firstChildIdx; i < children.length; i++) {
82
82
  const child = children[i]
83
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.
84
86
  for (const c of child) {
85
87
  element.append(renderNode(c))
86
88
  }
@@ -500,6 +502,15 @@ function doBindChildren (parent, element) {
500
502
  return parent
501
503
  }
502
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
+
503
514
  const doBind = function (ctx, element, handleReplace) {
504
515
  if (typeof element !== 'function') {
505
516
  throw new Error('You must pass a function to bind with')
@@ -512,6 +523,7 @@ const doBind = function (ctx, element, handleReplace) {
512
523
  const thisRenderCleanups = []
513
524
  activeRenderCleanups = thisRenderCleanups
514
525
  const evaluated = evaluateElement(element, ctx.currentValue)
526
+ assertSingleNode(evaluated, 'bindAs')
515
527
  const childElCtx = (typeof evaluated === 'function' && evaluated.isBindGetter)
516
528
  ? evaluated.elCtx
517
529
  : null
@@ -611,6 +623,7 @@ const updateReplacer = (ctx, element, elCtx) => (_, oldValue) => {
611
623
  const thisRenderCleanups = []
612
624
  activeRenderCleanups = thisRenderCleanups
613
625
  const evaluated = evaluateElement(element, ctx.currentValue, oldValue)
626
+ assertSingleNode(evaluated, 'bindAs')
614
627
  const childElCtx = (typeof evaluated === 'function' && evaluated.isBindGetter)
615
628
  ? evaluated.elCtx
616
629
  : null
@@ -790,7 +803,9 @@ function arrangeElements (ctx, bindContext, oldState) {
790
803
  const thisElementCleanups = []
791
804
  activeRenderCleanups = thisElementCleanups
792
805
  // Pass the valueState (the fnstate wrapper) to the user's element builder
793
- 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)
794
809
  activeRenderCleanups = prevCleanups
795
810
  bindContext.elementCleanups[key] = thisElementCleanups
796
811
  current.key = key
@@ -859,7 +874,8 @@ const evaluateElement = (element, value, oldValue) => {
859
874
  }
860
875
 
861
876
  /**
862
- * 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.
863
879
  * @param {any} node The node to render
864
880
  * @returns {Node} The rendered node
865
881
  */
@@ -868,6 +884,15 @@ export function renderNode (node) {
868
884
  const element = h('div')
869
885
  node(element, 'node')
870
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
871
896
  } else if (node && typeof node === 'object') {
872
897
  if (typeof node.then === 'function') {
873
898
  let temp = h('div', { style: 'display:none', class: 'fntags-promise-marker' })