navgo 3.0.5 → 3.0.7
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/index.d.ts +15 -0
- package/index.js +155 -35
- package/package.json +6 -2
- package/readme.md +74 -17
package/index.d.ts
CHANGED
|
@@ -75,10 +75,17 @@ export interface Options {
|
|
|
75
75
|
preload_delay?: number
|
|
76
76
|
/** Disable hover/touch preloading when `false`. Default true. */
|
|
77
77
|
preload_on_hover?: boolean
|
|
78
|
+
/** Attach instance to window as `window.navgo`. Default true. */
|
|
79
|
+
attach_to_window?: boolean
|
|
78
80
|
/** Global hook fired after per-route `before_route_leave`, before loaders/history change. Can cancel. */
|
|
79
81
|
before_navigate?(_nav: Navigation): void
|
|
80
82
|
/** Global hook fired after routing completes (data loaded, URL updated, handlers run). */
|
|
81
83
|
after_navigate?(_nav: Navigation): void
|
|
84
|
+
/** Optional hook awaited after `after_navigate` and before scroll handling.
|
|
85
|
+
* Useful for UI frameworks (e.g., Svelte) to flush DOM updates so anchor/top
|
|
86
|
+
* scrolling lands on the correct elements.
|
|
87
|
+
*/
|
|
88
|
+
tick?: () => void | Promise<void>
|
|
82
89
|
/** Global hook fired whenever the URL changes.
|
|
83
90
|
* Triggers for shallow pushes/replaces, hash changes, popstate-shallow, 404s, and full navigations.
|
|
84
91
|
* Receives the router's current snapshot (eg `{ url: URL, route: RouteTuple|null, params: Params }`).
|
|
@@ -105,6 +112,14 @@ export default class Navgo<T = unknown> {
|
|
|
105
112
|
init(): Promise<void>
|
|
106
113
|
/** Remove listeners installed by `init()`. */
|
|
107
114
|
destroy(): void
|
|
115
|
+
/** Writable store with current { url, route, params }. */
|
|
116
|
+
readonly route: import('svelte/store').Writable<{
|
|
117
|
+
url: URL
|
|
118
|
+
route: RouteTuple<T> | null
|
|
119
|
+
params: Params
|
|
120
|
+
}>
|
|
121
|
+
/** Writable store indicating active navigation. */
|
|
122
|
+
readonly is_navigating: import('svelte/store').Writable<boolean>
|
|
108
123
|
/** Built-in validator helpers (namespaced). */
|
|
109
124
|
static validators: ValidatorHelpers
|
|
110
125
|
}
|
package/index.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { parse } from 'regexparam'
|
|
2
|
+
import { tick } from 'svelte'
|
|
3
|
+
import { writable } from 'svelte/store'
|
|
2
4
|
|
|
3
5
|
const ℹ = (...args) => {}
|
|
4
6
|
|
|
5
7
|
export default class Navgo {
|
|
8
|
+
/** @type {Options} */
|
|
6
9
|
#opts = {
|
|
7
10
|
base: '/',
|
|
8
11
|
preload_delay: 20,
|
|
@@ -10,25 +13,39 @@ export default class Navgo {
|
|
|
10
13
|
before_navigate: undefined,
|
|
11
14
|
after_navigate: undefined,
|
|
12
15
|
url_changed: undefined,
|
|
16
|
+
tick,
|
|
17
|
+
attach_to_window: true,
|
|
13
18
|
}
|
|
19
|
+
/** @type {Array<{ pattern: RegExp, keys: string[]|null, data: RouteTuple }>} */
|
|
14
20
|
#routes = []
|
|
21
|
+
/** @type {string} */
|
|
15
22
|
#base = '/'
|
|
23
|
+
/** @type {RegExp} */
|
|
16
24
|
#base_rgx = /^\/+/
|
|
17
|
-
|
|
25
|
+
/** @type {Map<string, { promise?: Promise<any>, data?: any }>} */
|
|
18
26
|
#preloads = new Map()
|
|
19
|
-
|
|
27
|
+
/** @type {{ url: URL|null, route: RouteTuple|null, params: Params }} */
|
|
20
28
|
#current = { url: null, route: null, params: {} }
|
|
29
|
+
/** @type {number} */
|
|
21
30
|
#route_idx = 0
|
|
22
|
-
|
|
31
|
+
/** @type {boolean} */
|
|
23
32
|
#hash_navigating = false
|
|
33
|
+
/** @type {Map<number, Map<string, { x: number, y: number }>>} */
|
|
34
|
+
#areas_pos = new Map()
|
|
24
35
|
// Latest-wins nav guard: monotonic id and currently active id
|
|
36
|
+
/** @type {number} */
|
|
25
37
|
#nav_seq = 0
|
|
38
|
+
/** @type {number} */
|
|
26
39
|
#nav_active = 0
|
|
40
|
+
/** @type {(e: Event) => void | null} */
|
|
41
|
+
#scroll_handler = null
|
|
42
|
+
route = writable({ url: new URL(location.href), route: null, params: {} })
|
|
43
|
+
is_navigating = writable(false)
|
|
27
44
|
|
|
28
45
|
//
|
|
29
46
|
// Event listeners
|
|
30
47
|
//
|
|
31
|
-
#click = e => {
|
|
48
|
+
#click = async e => {
|
|
32
49
|
ℹ('[🧭 event:click]', { type: e?.type, target: e?.target })
|
|
33
50
|
const info = this.#link_from_event(e, true)
|
|
34
51
|
if (!info) return
|
|
@@ -53,12 +70,23 @@ export default class Navgo {
|
|
|
53
70
|
|
|
54
71
|
// different hash on same path — let browser update URL + scroll
|
|
55
72
|
this.#hash_navigating = true
|
|
56
|
-
this.#save_scroll()
|
|
57
73
|
ℹ('[🧭 hash]', 'navigate', { href: url.href })
|
|
58
74
|
return
|
|
59
75
|
}
|
|
60
76
|
|
|
61
77
|
e.preventDefault()
|
|
78
|
+
|
|
79
|
+
// allow the browser to repaint before navigating —
|
|
80
|
+
// this prevents INP scores being penalised
|
|
81
|
+
await new Promise(fulfil => {
|
|
82
|
+
requestAnimationFrame(() => {
|
|
83
|
+
setTimeout(fulfil, 0)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
// fallback for edge case where rAF doesn't fire because e.g. tab was backgrounded
|
|
87
|
+
setTimeout(fulfil, 100)
|
|
88
|
+
})
|
|
89
|
+
|
|
62
90
|
ℹ('[🧭 link]', 'intercept', { href: info.href })
|
|
63
91
|
this.goto(info.href, { replace: false }, 'link', e)
|
|
64
92
|
}
|
|
@@ -76,7 +104,7 @@ export default class Navgo {
|
|
|
76
104
|
this.#current.url = target
|
|
77
105
|
ℹ(' - [🧭 event:popstate]', 'same path+search; skip loaders')
|
|
78
106
|
this.#apply_scroll(ev)
|
|
79
|
-
this
|
|
107
|
+
this.route.set(this.#current)
|
|
80
108
|
return
|
|
81
109
|
}
|
|
82
110
|
// Explicit shallow entries (pushState/replaceState) regardless of path
|
|
@@ -84,7 +112,7 @@ export default class Navgo {
|
|
|
84
112
|
this.#current.url = target
|
|
85
113
|
ℹ(' - [🧭 event:popstate]', 'shallow entry; skip loaders')
|
|
86
114
|
this.#apply_scroll(ev)
|
|
87
|
-
this
|
|
115
|
+
this.route.set(this.#current)
|
|
88
116
|
return
|
|
89
117
|
}
|
|
90
118
|
|
|
@@ -101,12 +129,28 @@ export default class Navgo {
|
|
|
101
129
|
history.replaceState(next_state, '', location.href)
|
|
102
130
|
this.#route_idx = next_idx
|
|
103
131
|
ℹ('[🧭 event:hashchange]', { idx: next_idx, href: location.href })
|
|
132
|
+
} else {
|
|
133
|
+
// hashchange via Back/Forward — restore previous position when hash is removed
|
|
134
|
+
const idx = history.state?.__navgo?.idx
|
|
135
|
+
if (typeof idx === 'number') this.#route_idx = idx
|
|
136
|
+
if (!location.hash) {
|
|
137
|
+
const pos = this.#areas_pos.get(this.#route_idx)?.get?.('window')
|
|
138
|
+
if (pos) {
|
|
139
|
+
scrollTo(pos.x, pos.y)
|
|
140
|
+
ℹ('[🧭 scroll]', 'restore hash-back', { idx: this.#route_idx, ...pos })
|
|
141
|
+
} else {
|
|
142
|
+
// no saved position for previous entry — default to top
|
|
143
|
+
scrollTo(0, 0)
|
|
144
|
+
ℹ('[🧭 scroll]', 'hash-back -> top')
|
|
145
|
+
}
|
|
146
|
+
}
|
|
104
147
|
}
|
|
105
148
|
// update current URL snapshot and notify
|
|
106
149
|
this.#current.url = new URL(location.href)
|
|
107
|
-
this
|
|
150
|
+
this.route.set(this.#current)
|
|
108
151
|
}
|
|
109
152
|
|
|
153
|
+
/** @type {any} */
|
|
110
154
|
#hover_timer = null
|
|
111
155
|
#maybe_preload = ev => {
|
|
112
156
|
const info = this.#link_from_event(ev, ev.type === 'mousedown')
|
|
@@ -121,6 +165,24 @@ export default class Navgo {
|
|
|
121
165
|
}
|
|
122
166
|
#tap = ev => this.#maybe_preload(ev)
|
|
123
167
|
|
|
168
|
+
#on_scroll = e => {
|
|
169
|
+
// prevent hash from overwriting the previous entry’s saved position.
|
|
170
|
+
if (this.#hash_navigating) return
|
|
171
|
+
|
|
172
|
+
const el = e?.target
|
|
173
|
+
const id =
|
|
174
|
+
!el || el === window || el === document ? 'window' : el?.dataset?.scrollId || el?.id
|
|
175
|
+
if (!id) return
|
|
176
|
+
const pos =
|
|
177
|
+
id === 'window'
|
|
178
|
+
? { x: scrollX || 0, y: scrollY || 0 }
|
|
179
|
+
: { x: el.scrollLeft || 0, y: el.scrollTop || 0 }
|
|
180
|
+
const m = this.#areas_pos.get(this.#route_idx) || new Map()
|
|
181
|
+
m.set(String(id), pos)
|
|
182
|
+
this.#areas_pos.set(this.#route_idx, m)
|
|
183
|
+
ℹ('[🧭 scroll:set]', this.#route_idx, m)
|
|
184
|
+
}
|
|
185
|
+
|
|
124
186
|
#before_unload = ev => {
|
|
125
187
|
// persist scroll for refresh / session restore
|
|
126
188
|
try {
|
|
@@ -178,7 +240,6 @@ export default class Navgo {
|
|
|
178
240
|
!a.target &&
|
|
179
241
|
!a.download &&
|
|
180
242
|
a.host === location.host &&
|
|
181
|
-
href[0] != '#' &&
|
|
182
243
|
this.#base_rgx.test(a.pathname)
|
|
183
244
|
? { a, href }
|
|
184
245
|
: null
|
|
@@ -226,8 +287,6 @@ export default class Navgo {
|
|
|
226
287
|
}
|
|
227
288
|
|
|
228
289
|
/**
|
|
229
|
-
* Programmatic navigation that runs loaders before changing URL.
|
|
230
|
-
* Also used by popstate to unify the flow.
|
|
231
290
|
* @param {string} [url_raw]
|
|
232
291
|
* @param {{ replace?: boolean }} [opts]
|
|
233
292
|
* @param {'goto'|'link'|'popstate'} [nav_type]
|
|
@@ -242,6 +301,7 @@ export default class Navgo {
|
|
|
242
301
|
ℹ('[🧭 goto]', 'invalid url', { url: url_raw })
|
|
243
302
|
return
|
|
244
303
|
}
|
|
304
|
+
this.is_navigating.set(true)
|
|
245
305
|
const { url, path } = info
|
|
246
306
|
|
|
247
307
|
const is_popstate = nav_type === 'popstate'
|
|
@@ -271,6 +331,7 @@ export default class Navgo {
|
|
|
271
331
|
}
|
|
272
332
|
}
|
|
273
333
|
}
|
|
334
|
+
if (nav_id === this.#nav_active) this.is_navigating.set(false)
|
|
274
335
|
ℹ('[🧭 goto]', 'cancelled by before_route_leave')
|
|
275
336
|
return
|
|
276
337
|
}
|
|
@@ -283,7 +344,6 @@ export default class Navgo {
|
|
|
283
344
|
from: nav.from?.url?.href,
|
|
284
345
|
to: url.href,
|
|
285
346
|
})
|
|
286
|
-
this.#save_scroll()
|
|
287
347
|
const hit = await this.match(path)
|
|
288
348
|
if (nav_id !== this.#nav_active) return
|
|
289
349
|
|
|
@@ -351,6 +411,7 @@ export default class Navgo {
|
|
|
351
411
|
})
|
|
352
412
|
// await so that apply_scroll is after potential async work
|
|
353
413
|
await this.#opts.after_navigate?.(nav)
|
|
414
|
+
|
|
354
415
|
if (nav_id !== this.#nav_active) return
|
|
355
416
|
ℹ('[🧭 navigate]', hit ? 'done' : 'done (404)', {
|
|
356
417
|
from: nav.from?.url?.href,
|
|
@@ -358,18 +419,34 @@ export default class Navgo {
|
|
|
358
419
|
type: nav.type,
|
|
359
420
|
idx: this.#route_idx,
|
|
360
421
|
})
|
|
422
|
+
|
|
423
|
+
// allow frameworks to flush DOM before scrolling
|
|
424
|
+
await this.#opts.tick?.()
|
|
425
|
+
|
|
361
426
|
this.#apply_scroll(nav)
|
|
362
|
-
this
|
|
427
|
+
this.route.set(this.#current)
|
|
428
|
+
if (nav_id === this.#nav_active) this.is_navigating.set(false)
|
|
363
429
|
}
|
|
364
430
|
|
|
365
431
|
/**
|
|
366
432
|
* Shallow push — updates the URL/state but DOES NOT call handlers or loaders.
|
|
367
|
-
* URL changes, content stays put until a real nav.
|
|
368
433
|
*/
|
|
369
434
|
#commit_shallow(url, state, replace) {
|
|
370
435
|
const u = new URL(url || location.href, location.href)
|
|
371
|
-
//
|
|
372
|
-
|
|
436
|
+
// persist current entry's scroll into its state so Back after refresh restores it
|
|
437
|
+
const prev = history.state && typeof history.state == 'object' ? history.state : {}
|
|
438
|
+
history.replaceState(
|
|
439
|
+
{ ...prev, __navgo: { ...prev.__navgo, pos: { x: scrollX || 0, y: scrollY || 0 } } },
|
|
440
|
+
'',
|
|
441
|
+
location.href,
|
|
442
|
+
)
|
|
443
|
+
// also stash per-URL scroll in session storage as a fallback across reloads
|
|
444
|
+
try {
|
|
445
|
+
sessionStorage.setItem(
|
|
446
|
+
`__navgo_scroll:${location.href}`,
|
|
447
|
+
JSON.stringify({ x: scrollX || 0, y: scrollY || 0 }),
|
|
448
|
+
)
|
|
449
|
+
} catch {}
|
|
373
450
|
const idx = this.#route_idx + (replace ? 0 : 1)
|
|
374
451
|
const st = { ...state, __navgo: { ...state?.__navgo, shallow: true, idx } }
|
|
375
452
|
history[(replace ? 'replace' : 'push') + 'State'](st, '', u.href)
|
|
@@ -379,12 +456,14 @@ export default class Navgo {
|
|
|
379
456
|
})
|
|
380
457
|
// Popstate handler checks state.__navgo.shallow and skips router processing
|
|
381
458
|
this.#route_idx = idx
|
|
382
|
-
// carry forward current
|
|
383
|
-
this.#
|
|
459
|
+
// carry forward current window position for the shallow entry so Forward restores correctly
|
|
460
|
+
const m = this.#areas_pos.get(idx) || new Map()
|
|
461
|
+
m.set('window', { x: scrollX || 0, y: scrollY || 0 })
|
|
462
|
+
this.#areas_pos.set(idx, m)
|
|
384
463
|
if (!replace) this.#clear_onward_history()
|
|
385
464
|
// update current URL snapshot and notify
|
|
386
465
|
this.#current.url = u
|
|
387
|
-
this
|
|
466
|
+
this.route.set(this.#current)
|
|
388
467
|
}
|
|
389
468
|
|
|
390
469
|
/** @param {string|URL} [url] @param {any} [state] */
|
|
@@ -492,6 +571,11 @@ export default class Navgo {
|
|
|
492
571
|
return pat
|
|
493
572
|
})
|
|
494
573
|
|
|
574
|
+
// TODO: deprecated, remove later
|
|
575
|
+
this.route.subscribe(() => {
|
|
576
|
+
this.#opts.url_changed?.(this.#current)
|
|
577
|
+
})
|
|
578
|
+
|
|
495
579
|
ℹ('[🧭 init]', {
|
|
496
580
|
base: this.#base,
|
|
497
581
|
routes: this.#routes.length,
|
|
@@ -511,6 +595,8 @@ export default class Navgo {
|
|
|
511
595
|
addEventListener('click', this.#click)
|
|
512
596
|
addEventListener('beforeunload', this.#before_unload)
|
|
513
597
|
addEventListener('hashchange', this.#on_hashchange)
|
|
598
|
+
this.#scroll_handler = throttle(this.#on_scroll, 100)
|
|
599
|
+
addEventListener('scroll', this.#scroll_handler, { capture: true })
|
|
514
600
|
|
|
515
601
|
if (this.#opts.preload_on_hover) {
|
|
516
602
|
// @ts-expect-error
|
|
@@ -535,6 +621,7 @@ export default class Navgo {
|
|
|
535
621
|
}
|
|
536
622
|
|
|
537
623
|
ℹ('[🧭 init]', 'initial goto')
|
|
624
|
+
if (this.#opts.attach_to_window) window.navgo = this
|
|
538
625
|
return this.goto()
|
|
539
626
|
}
|
|
540
627
|
destroy() {
|
|
@@ -545,18 +632,12 @@ export default class Navgo {
|
|
|
545
632
|
removeEventListener('mousedown', this.#tap)
|
|
546
633
|
removeEventListener('beforeunload', this.#before_unload)
|
|
547
634
|
removeEventListener('hashchange', this.#on_hashchange)
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
//
|
|
551
|
-
// Scroll
|
|
552
|
-
//
|
|
553
|
-
#save_scroll() {
|
|
554
|
-
this.#scroll.set(this.#route_idx, { x: scrollX, y: scrollY })
|
|
555
|
-
ℹ('[🧭 scroll]', 'save', { idx: this.#route_idx, x: scrollX, y: scrollY })
|
|
635
|
+
removeEventListener('scroll', this.#scroll_handler, { capture: true })
|
|
636
|
+
this.#areas_pos.clear()
|
|
556
637
|
}
|
|
557
638
|
|
|
558
639
|
#clear_onward_history() {
|
|
559
|
-
for (const k of this.#
|
|
640
|
+
for (const k of this.#areas_pos.keys()) if (k > this.#route_idx) this.#areas_pos.delete(k)
|
|
560
641
|
ℹ('[🧭 scroll]', 'clear onward', { upto: this.#route_idx })
|
|
561
642
|
}
|
|
562
643
|
|
|
@@ -579,18 +660,36 @@ export default class Navgo {
|
|
|
579
660
|
// 1) On back/forward, restore saved position if available
|
|
580
661
|
if (t === 'popstate') {
|
|
581
662
|
const ev_state = ctx?.state ?? ctx?.event?.state
|
|
582
|
-
const
|
|
663
|
+
const st = ev_state?.__navgo
|
|
664
|
+
const idx = st?.idx
|
|
583
665
|
const target_idx = typeof idx === 'number' ? idx : this.#route_idx - 1
|
|
584
666
|
this.#route_idx = target_idx
|
|
585
|
-
const
|
|
667
|
+
const m = this.#areas_pos.get(target_idx)
|
|
668
|
+
let pos = st?.pos || m?.get?.('window')
|
|
669
|
+
if (!pos) {
|
|
670
|
+
try {
|
|
671
|
+
const k = `__navgo_scroll:${location.href}`
|
|
672
|
+
pos = JSON.parse(sessionStorage.getItem(k)) || null
|
|
673
|
+
sessionStorage.removeItem(k)
|
|
674
|
+
} catch {}
|
|
675
|
+
}
|
|
586
676
|
if (pos) {
|
|
587
677
|
scrollTo(pos.x, pos.y)
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
})
|
|
592
|
-
return
|
|
678
|
+
// re-apply on next tick to resist late reflows (e.g. images)
|
|
679
|
+
setTimeout(() => scrollTo(pos.x, pos.y), 0)
|
|
680
|
+
ℹ('[🧭 scroll]', 'restore popstate', { idx: target_idx, ...pos })
|
|
593
681
|
}
|
|
682
|
+
for (const [id, p] of m || []) {
|
|
683
|
+
if (id === 'window') continue
|
|
684
|
+
const sel = `[data-scroll-id="${CSS.escape(id)}"],` + `#${CSS.escape(id)}`
|
|
685
|
+
const el = document.querySelector(sel)
|
|
686
|
+
if (el) {
|
|
687
|
+
el.scrollTo?.(p.x, p.y)
|
|
688
|
+
el.scrollLeft = p.x
|
|
689
|
+
el.scrollTop = p.y
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
if (pos || m) return
|
|
594
693
|
}
|
|
595
694
|
// 2) If there is a hash, prefer anchor scroll
|
|
596
695
|
if (hash && this.#scroll_to_hash(hash)) {
|
|
@@ -635,6 +734,27 @@ export default class Navgo {
|
|
|
635
734
|
}
|
|
636
735
|
}
|
|
637
736
|
|
|
737
|
+
function throttle(fn, ms) {
|
|
738
|
+
let t,
|
|
739
|
+
last = 0
|
|
740
|
+
return e => {
|
|
741
|
+
const now = Date.now()
|
|
742
|
+
if (now - last >= ms) {
|
|
743
|
+
last = now
|
|
744
|
+
fn(e)
|
|
745
|
+
} else {
|
|
746
|
+
clearTimeout(t)
|
|
747
|
+
t = setTimeout(
|
|
748
|
+
() => {
|
|
749
|
+
last = Date.now()
|
|
750
|
+
fn(e)
|
|
751
|
+
},
|
|
752
|
+
ms - (now - last),
|
|
753
|
+
)
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
|
|
638
758
|
/** @typedef {import('./index.d.ts').Options} Options */
|
|
639
759
|
/** @typedef {import('./index.d.ts').RouteTuple} RouteTuple */
|
|
640
760
|
/** @typedef {import('./index.d.ts').MatchResult} MatchResult */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "navgo",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.7",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/mustafa0x/navgo.git"
|
|
@@ -16,9 +16,10 @@
|
|
|
16
16
|
},
|
|
17
17
|
"scripts": {
|
|
18
18
|
"build": "perl -0777 -i -pe 's/console.debug\\(...args\\)/{}/g' index.js",
|
|
19
|
-
"prepublishOnly": "pnpm run build",
|
|
19
|
+
"prepublishOnly": "pnpm test && pnpm test:e2e && pnpm run build",
|
|
20
20
|
"test": "vitest run index.test.js",
|
|
21
21
|
"test:e2e": "playwright test",
|
|
22
|
+
"start:testsite": "pnpm vite dev test/site --port 5714",
|
|
22
23
|
"types": "tsc -p test/types"
|
|
23
24
|
},
|
|
24
25
|
"files": [
|
|
@@ -33,6 +34,9 @@
|
|
|
33
34
|
"dependencies": {
|
|
34
35
|
"regexparam": "^3.0.0"
|
|
35
36
|
},
|
|
37
|
+
"peerDependencies": {
|
|
38
|
+
"svelte": "5"
|
|
39
|
+
},
|
|
36
40
|
"devDependencies": {
|
|
37
41
|
"@eslint/js": "^9.37.0",
|
|
38
42
|
"@playwright/test": "^1.56.0",
|
package/readme.md
CHANGED
|
@@ -51,6 +51,9 @@ const router = new Navgo(routes, {
|
|
|
51
51
|
|
|
52
52
|
console.log('after_navigate', nav.to?.url.pathname, nav.to?.data)
|
|
53
53
|
},
|
|
54
|
+
// let your framework flush DOM before scroll
|
|
55
|
+
// e.g. in Svelte: `import { tick } from 'svelte'`
|
|
56
|
+
tick: tick,
|
|
54
57
|
url_changed(cur) {
|
|
55
58
|
// fires on shallow/hash/popstate-shallow/404 and full navigations
|
|
56
59
|
// `cur` is the router snapshot: { url: URL, route, params }
|
|
@@ -97,17 +100,41 @@ Notes:
|
|
|
97
100
|
- App-level hook called once per navigation attempt after the per-route guard and before loaders/URL update. May call `nav.cancel()` synchronously to prevent navigation.
|
|
98
101
|
- `after_navigate`: `(nav: Navigation) => void`
|
|
99
102
|
- App-level hook called after routing completes (URL updated, data loaded). `nav.to.data` holds any loader data.
|
|
103
|
+
- `tick`: `() => void | Promise<void>`
|
|
104
|
+
- Awaited after `after_navigate` and before scroll handling; useful for frameworks to flush DOM so anchor/top scrolling lands correctly.
|
|
100
105
|
- `url_changed`: `(snapshot: any) => void`
|
|
101
|
-
- Fires on every URL change
|
|
106
|
+
- Fires on every URL change -- shallow `push_state`/`replace_state`, hash changes, `popstate` shallow entries, 404s, and full navigations. (deprecated; subscribe to `.route` instead.)
|
|
102
107
|
- Receives the router's current snapshot: an object like `{ url: URL, route: RouteTuple|null, params: Params }`.
|
|
103
108
|
- The snapshot type is intentionally `any` and may evolve without a breaking change.
|
|
104
109
|
- `preload_delay`: `number` (default `20`)
|
|
105
110
|
- Delay in ms before hover preloading triggers.
|
|
106
111
|
- `preload_on_hover`: `boolean` (default `true`)
|
|
107
112
|
- When `false`, disables hover/touch preloading.
|
|
113
|
+
- `attach_to_window`: `boolean` (default `true`)
|
|
114
|
+
- When `true`, `init()` attaches the instance to `window.navgo` for convenience (e.g., devtools, simple Svelte scripts).
|
|
108
115
|
|
|
109
116
|
Important: Navgo only processes routes that match your `base` path.
|
|
110
117
|
|
|
118
|
+
### Instance stores
|
|
119
|
+
|
|
120
|
+
- `router.route` -- `Writable<{ url: URL; route: RouteTuple|null; params: Params }>`
|
|
121
|
+
- Readonly property that holds the current snapshot.
|
|
122
|
+
- Subscribe to react to changes; Navgo updates it on every URL change.
|
|
123
|
+
- `router.is_navigating` -- `Writable<boolean>`
|
|
124
|
+
- `true` while a navigation is in flight (between start and completion/cancel).
|
|
125
|
+
|
|
126
|
+
Example:
|
|
127
|
+
|
|
128
|
+
```svelte
|
|
129
|
+
Current path: {$route.path}
|
|
130
|
+
<div class="request-indicator" class:active={$is_navigating}></div>
|
|
131
|
+
|
|
132
|
+
<script>
|
|
133
|
+
const router = new Navgo(...)
|
|
134
|
+
const {route, is_navigating} = router
|
|
135
|
+
</script>
|
|
136
|
+
```
|
|
137
|
+
|
|
111
138
|
### Route Hooks
|
|
112
139
|
|
|
113
140
|
- param_validators?: `Record<string, (value: string|null|undefined) => boolean>`
|
|
@@ -220,8 +247,8 @@ While listening, link clicks are intercepted and translated into `goto()` naviga
|
|
|
220
247
|
|
|
221
248
|
In addition, `init()` wires preloading listeners (enabled by default) so route data can be fetched early:
|
|
222
249
|
|
|
223
|
-
- `mousemove` (hover)
|
|
224
|
-
- `touchstart` and `mousedown` (tap)
|
|
250
|
+
- `mousemove` (hover) -- after a short delay, hovering an in-app link triggers `preload(href)`.
|
|
251
|
+
- `touchstart` and `mousedown` (tap) -- tapping or pressing on an in-app link also triggers `preload(href)`.
|
|
225
252
|
|
|
226
253
|
Preloading applies only to in-app anchors that match the configured [`base`](#base). You can tweak this behavior with the `preload_delay` and `preload_on_hover` options.
|
|
227
254
|
|
|
@@ -233,6 +260,26 @@ Notes:
|
|
|
233
260
|
|
|
234
261
|
On `beforeunload`, the current scroll position is saved to `sessionStorage` and restored on the next load of the same URL (e.g., refresh or tab restore).
|
|
235
262
|
|
|
263
|
+
### Scroll Restoration (areas)
|
|
264
|
+
|
|
265
|
+
Navgo caches/restores scroll positions for the window and any scrollable element that has a stable identifier:
|
|
266
|
+
|
|
267
|
+
- Give your element either an `id` or `data-scroll-id="..."`.
|
|
268
|
+
- Navgo listens to `scroll` globally (capture) and records positions per history entry.
|
|
269
|
+
- On `popstate`, it restores matching elements before paint.
|
|
270
|
+
|
|
271
|
+
Example:
|
|
272
|
+
|
|
273
|
+
```html
|
|
274
|
+
<div id="pane" class="overflow-auto">...</div>
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
Or with a custom id:
|
|
278
|
+
|
|
279
|
+
```html
|
|
280
|
+
<div data-scroll-id="pane">...</div>
|
|
281
|
+
```
|
|
282
|
+
|
|
236
283
|
### preload(uri)
|
|
237
284
|
|
|
238
285
|
Returns: `Promise<unknown | void>`
|
|
@@ -262,10 +309,10 @@ This section explains, in detail, how navigation is processed: matching, hooks,
|
|
|
262
309
|
|
|
263
310
|
### Navigation Types
|
|
264
311
|
|
|
265
|
-
- `link`
|
|
266
|
-
- `goto`
|
|
267
|
-
- `popstate`
|
|
268
|
-
- `leave`
|
|
312
|
+
- `link` -- user clicked an in-app `<a>` that matches `base`.
|
|
313
|
+
- `goto` -- programmatic navigation via `router.goto(...)`.
|
|
314
|
+
- `popstate` -- browser back/forward.
|
|
315
|
+
- `leave` -- page is unloading (refresh, external navigation, tab close) via `beforeunload`.
|
|
269
316
|
|
|
270
317
|
The router passes the type to your route-level `before_route_leave(nav)` hook.
|
|
271
318
|
|
|
@@ -292,6 +339,8 @@ For `link` and `goto` navigations that match a route:
|
|
|
292
339
|
→ cache data by formatted path
|
|
293
340
|
→ history.push/replaceState(new URL)
|
|
294
341
|
→ after_navigate(nav)
|
|
342
|
+
→ tick()? // optional app-provided await before scroll
|
|
343
|
+
→ scroll restore/hash/top
|
|
295
344
|
```
|
|
296
345
|
|
|
297
346
|
- If a loader throws/rejects, navigation continues and `after_navigate(..., with nav.to.data = { __error })` is delivered so UI can render an error state.
|
|
@@ -333,18 +382,26 @@ scroll flow
|
|
|
333
382
|
|
|
334
383
|
### Method-by-Method Semantics
|
|
335
384
|
|
|
336
|
-
- `format(uri)`
|
|
337
|
-
- `match(uri)`
|
|
338
|
-
- `goto(uri, { replace? })`
|
|
339
|
-
- `init()`
|
|
340
|
-
- `destroy()`
|
|
341
|
-
- `preload(uri)`
|
|
342
|
-
- `push_state(url?, state?)`
|
|
343
|
-
- `replace_state(url?, state?)`
|
|
385
|
+
- `format(uri)` -- normalizes a path relative to `base`. Returns `false` when `uri` is outside of `base`.
|
|
386
|
+
- `match(uri)` -- returns a Promise of `{ route, params } | null` using string/RegExp patterns and validators. Awaits an async `validate(params)` if provided.
|
|
387
|
+
- `goto(uri, { replace? })` -- fires route-level `before_route_leave('goto')`, calls global `before_navigate`, saves scroll, runs loaders, pushes/replaces, and completes via `after_navigate`.
|
|
388
|
+
- `init()` -- wires global listeners (`popstate`, `pushstate`, `replacestate`, click) and optional hover/tap preloading; immediately processes the current location.
|
|
389
|
+
- `destroy()` -- removes listeners added by `init()`.
|
|
390
|
+
- `preload(uri)` -- pre-executes a route's `loaders` for a path and caches the result; concurrent calls are deduped.
|
|
391
|
+
- `push_state(url?, state?)` -- shallow push that updates the URL and `history.state` without route processing.
|
|
392
|
+
- `replace_state(url?, state?)` -- shallow replace that updates the URL and `history.state` without route processing.
|
|
344
393
|
|
|
345
394
|
### Built-in Validators
|
|
346
395
|
|
|
347
|
-
- `Navgo.validators.int({ min?, max? })`
|
|
348
|
-
- `Navgo.validators.one_of(iterable)`
|
|
396
|
+
- `Navgo.validators.int({ min?, max? })` -- `true` iff the value is an integer within optional bounds.
|
|
397
|
+
- `Navgo.validators.one_of(iterable)` -- `true` iff the value is in the provided set.
|
|
349
398
|
|
|
350
399
|
Attach validators via a route tuple's `data.param_validators` to constrain matches.
|
|
400
|
+
|
|
401
|
+
# Credits
|
|
402
|
+
|
|
403
|
+
This router integrates ideas and small portions of code from these fantastic projects:
|
|
404
|
+
|
|
405
|
+
- SvelteKit -- https://github.com/sveltejs/kit
|
|
406
|
+
- navaid -- https://github.com/lukeed/navaid
|
|
407
|
+
- TanStack Router -- https://github.com/TanStack/router
|