navgo 3.0.4 → 3.0.6
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 +25 -20
- package/index.js +152 -45
- package/package.json +4 -2
- package/readme.md +53 -19
package/index.d.ts
CHANGED
|
@@ -7,23 +7,23 @@ export type Params = Record<string, string | null | undefined>
|
|
|
7
7
|
|
|
8
8
|
/** Built-in validator helpers shape. */
|
|
9
9
|
export interface ValidatorHelpers {
|
|
10
|
-
int(
|
|
10
|
+
int(_opts?: {
|
|
11
11
|
min?: number | null
|
|
12
12
|
max?: number | null
|
|
13
|
-
}): (
|
|
14
|
-
|
|
13
|
+
}): (_value: string | null | undefined) => boolean
|
|
14
|
+
one_of(_values: Iterable<string>): (_value: string | null | undefined) => boolean
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
/** Optional per-route hooks recognized by Navgo. */
|
|
18
18
|
export interface Hooks {
|
|
19
19
|
/** Validate params with custom per-param validators. Return `false` to skip a match. */
|
|
20
|
-
param_validators?: Record<string, (
|
|
20
|
+
param_validators?: Record<string, (_value: string | null | undefined) => boolean>
|
|
21
21
|
/** Load data for a route before navigation. May return a Promise or an array of values/promises. */
|
|
22
|
-
loaders?(
|
|
22
|
+
loaders?(_params: Params): unknown | Promise<unknown> | Array<unknown | Promise<unknown>>
|
|
23
23
|
/** Predicate used during match(); may be async. If it returns `false`, the route is skipped. */
|
|
24
|
-
validate?(
|
|
24
|
+
validate?(_params: Params): boolean | Promise<boolean>
|
|
25
25
|
/** Route-level navigation guard, called on the current route when leaving it. Synchronous only; call `nav.cancel()` to prevent navigation. */
|
|
26
|
-
|
|
26
|
+
before_route_leave?(_nav: Navigation): void
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
export interface NavigationTarget {
|
|
@@ -39,7 +39,7 @@ export interface Navigation {
|
|
|
39
39
|
type: 'link' | 'goto' | 'popstate' | 'leave'
|
|
40
40
|
from: NavigationTarget | null
|
|
41
41
|
to: NavigationTarget | null
|
|
42
|
-
|
|
42
|
+
will_unload: boolean
|
|
43
43
|
cancelled: boolean
|
|
44
44
|
/** The original browser event that initiated navigation, when available. */
|
|
45
45
|
event?: Event
|
|
@@ -62,7 +62,7 @@ export type Router<T = unknown> = Navgo<T>
|
|
|
62
62
|
export interface NavgoHistoryMeta {
|
|
63
63
|
/** Monotonic index of the current history entry for scroll restoration. */
|
|
64
64
|
idx: number
|
|
65
|
-
/** Present when the entry was created via shallow `
|
|
65
|
+
/** Present when the entry was created via shallow `push_state`/`replace_state`. */
|
|
66
66
|
shallow?: boolean
|
|
67
67
|
/** Origin of the navigation that created this entry. */
|
|
68
68
|
type?: 'link' | 'goto' | 'popstate'
|
|
@@ -75,32 +75,37 @@ 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
|
-
/** Global hook fired after per-route `
|
|
79
|
-
before_navigate?(
|
|
78
|
+
/** Global hook fired after per-route `before_route_leave`, before loaders/history change. Can cancel. */
|
|
79
|
+
before_navigate?(_nav: Navigation): void
|
|
80
80
|
/** Global hook fired after routing completes (data loaded, URL updated, handlers run). */
|
|
81
|
-
after_navigate?(
|
|
81
|
+
after_navigate?(_nav: Navigation): void
|
|
82
|
+
/** Optional hook awaited after `after_navigate` and before scroll handling.
|
|
83
|
+
* Useful for UI frameworks (e.g., Svelte) to flush DOM updates so anchor/top
|
|
84
|
+
* scrolling lands on the correct elements.
|
|
85
|
+
*/
|
|
86
|
+
tick?: () => void | Promise<void>
|
|
82
87
|
/** Global hook fired whenever the URL changes.
|
|
83
88
|
* Triggers for shallow pushes/replaces, hash changes, popstate-shallow, 404s, and full navigations.
|
|
84
89
|
* Receives the router's current snapshot (eg `{ url: URL, route: RouteTuple|null, params: Params }`).
|
|
85
90
|
*/
|
|
86
|
-
url_changed?(
|
|
91
|
+
url_changed?(_payload: any): void
|
|
87
92
|
}
|
|
88
93
|
|
|
89
94
|
/** Navgo default export: class-based router. */
|
|
90
95
|
export default class Navgo<T = unknown> {
|
|
91
|
-
constructor(
|
|
96
|
+
constructor(_routes?: Array<RouteTuple<T>>, _opts?: Options)
|
|
92
97
|
/** Format `url` relative to the configured base. */
|
|
93
|
-
format(
|
|
98
|
+
format(_url: string): string | false
|
|
94
99
|
/** SvelteKit-like navigation that runs loaders before updating the URL. */
|
|
95
|
-
goto(
|
|
100
|
+
goto(_url: string, _opts?: { replace?: boolean }): Promise<void>
|
|
96
101
|
/** Shallow push — updates URL/state without triggering handlers. */
|
|
97
|
-
|
|
102
|
+
push_state(_url?: string | URL, _state?: any): void
|
|
98
103
|
/** Shallow replace — updates URL/state without triggering handlers. */
|
|
99
|
-
|
|
104
|
+
replace_state(_url?: string | URL, _state?: any): void
|
|
100
105
|
/** Manually preload loaders for a URL (deduped). */
|
|
101
|
-
preload(
|
|
106
|
+
preload(_url: string): Promise<unknown | void>
|
|
102
107
|
/** Try to match `url`; returns route tuple and params or `null`. Supports async `validate`. */
|
|
103
|
-
match(
|
|
108
|
+
match(_url: string): Promise<MatchResult<T> | null>
|
|
104
109
|
/** Attach history + click listeners and immediately process current location. */
|
|
105
110
|
init(): Promise<void>
|
|
106
111
|
/** Remove listeners installed by `init()`. */
|
package/index.js
CHANGED
|
@@ -3,6 +3,7 @@ import { parse } from 'regexparam'
|
|
|
3
3
|
const ℹ = (...args) => {}
|
|
4
4
|
|
|
5
5
|
export default class Navgo {
|
|
6
|
+
/** @type {Options} */
|
|
6
7
|
#opts = {
|
|
7
8
|
base: '/',
|
|
8
9
|
preload_delay: 20,
|
|
@@ -10,25 +11,36 @@ export default class Navgo {
|
|
|
10
11
|
before_navigate: undefined,
|
|
11
12
|
after_navigate: undefined,
|
|
12
13
|
url_changed: undefined,
|
|
14
|
+
tick: undefined,
|
|
13
15
|
}
|
|
16
|
+
/** @type {Array<{ pattern: RegExp, keys: string[]|null, data: RouteTuple }>} */
|
|
14
17
|
#routes = []
|
|
18
|
+
/** @type {string} */
|
|
15
19
|
#base = '/'
|
|
20
|
+
/** @type {RegExp} */
|
|
16
21
|
#base_rgx = /^\/+/
|
|
17
|
-
|
|
22
|
+
/** @type {Map<string, { promise?: Promise<any>, data?: any }>} */
|
|
18
23
|
#preloads = new Map()
|
|
19
|
-
|
|
24
|
+
/** @type {{ url: URL|null, route: RouteTuple|null, params: Params }} */
|
|
20
25
|
#current = { url: null, route: null, params: {} }
|
|
26
|
+
/** @type {number} */
|
|
21
27
|
#route_idx = 0
|
|
22
|
-
|
|
28
|
+
/** @type {boolean} */
|
|
23
29
|
#hash_navigating = false
|
|
30
|
+
/** @type {Map<number, Map<string, { x: number, y: number }>>} */
|
|
31
|
+
#areas_pos = new Map()
|
|
24
32
|
// Latest-wins nav guard: monotonic id and currently active id
|
|
33
|
+
/** @type {number} */
|
|
25
34
|
#nav_seq = 0
|
|
35
|
+
/** @type {number} */
|
|
26
36
|
#nav_active = 0
|
|
37
|
+
/** @type {(e: Event) => void | null} */
|
|
38
|
+
#scroll_handler = null
|
|
27
39
|
|
|
28
40
|
//
|
|
29
41
|
// Event listeners
|
|
30
42
|
//
|
|
31
|
-
#click = e => {
|
|
43
|
+
#click = async e => {
|
|
32
44
|
ℹ('[🧭 event:click]', { type: e?.type, target: e?.target })
|
|
33
45
|
const info = this.#link_from_event(e, true)
|
|
34
46
|
if (!info) return
|
|
@@ -53,12 +65,23 @@ export default class Navgo {
|
|
|
53
65
|
|
|
54
66
|
// different hash on same path — let browser update URL + scroll
|
|
55
67
|
this.#hash_navigating = true
|
|
56
|
-
this.#save_scroll()
|
|
57
68
|
ℹ('[🧭 hash]', 'navigate', { href: url.href })
|
|
58
69
|
return
|
|
59
70
|
}
|
|
60
71
|
|
|
61
72
|
e.preventDefault()
|
|
73
|
+
|
|
74
|
+
// allow the browser to repaint before navigating —
|
|
75
|
+
// this prevents INP scores being penalised
|
|
76
|
+
await new Promise(fulfil => {
|
|
77
|
+
requestAnimationFrame(() => {
|
|
78
|
+
setTimeout(fulfil, 0)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
// fallback for edge case where rAF doesn't fire because e.g. tab was backgrounded
|
|
82
|
+
setTimeout(fulfil, 100)
|
|
83
|
+
})
|
|
84
|
+
|
|
62
85
|
ℹ('[🧭 link]', 'intercept', { href: info.href })
|
|
63
86
|
this.goto(info.href, { replace: false }, 'link', e)
|
|
64
87
|
}
|
|
@@ -101,12 +124,28 @@ export default class Navgo {
|
|
|
101
124
|
history.replaceState(next_state, '', location.href)
|
|
102
125
|
this.#route_idx = next_idx
|
|
103
126
|
ℹ('[🧭 event:hashchange]', { idx: next_idx, href: location.href })
|
|
127
|
+
} else {
|
|
128
|
+
// hashchange via Back/Forward — restore previous position when hash is removed
|
|
129
|
+
const idx = history.state?.__navgo?.idx
|
|
130
|
+
if (typeof idx === 'number') this.#route_idx = idx
|
|
131
|
+
if (!location.hash) {
|
|
132
|
+
const pos = this.#areas_pos.get(this.#route_idx)?.get?.('window')
|
|
133
|
+
if (pos) {
|
|
134
|
+
scrollTo(pos.x, pos.y)
|
|
135
|
+
ℹ('[🧭 scroll]', 'restore hash-back', { idx: this.#route_idx, ...pos })
|
|
136
|
+
} else {
|
|
137
|
+
// no saved position for previous entry — default to top
|
|
138
|
+
scrollTo(0, 0)
|
|
139
|
+
ℹ('[🧭 scroll]', 'hash-back -> top')
|
|
140
|
+
}
|
|
141
|
+
}
|
|
104
142
|
}
|
|
105
143
|
// update current URL snapshot and notify
|
|
106
144
|
this.#current.url = new URL(location.href)
|
|
107
145
|
this.#opts.url_changed?.(this.#current)
|
|
108
146
|
}
|
|
109
147
|
|
|
148
|
+
/** @type {any} */
|
|
110
149
|
#hover_timer = null
|
|
111
150
|
#maybe_preload = ev => {
|
|
112
151
|
const info = this.#link_from_event(ev, ev.type === 'mousedown')
|
|
@@ -121,6 +160,24 @@ export default class Navgo {
|
|
|
121
160
|
}
|
|
122
161
|
#tap = ev => this.#maybe_preload(ev)
|
|
123
162
|
|
|
163
|
+
#on_scroll = e => {
|
|
164
|
+
// prevent hash from overwriting the previous entry’s saved position.
|
|
165
|
+
if (this.#hash_navigating) return
|
|
166
|
+
|
|
167
|
+
const el = e?.target
|
|
168
|
+
const id =
|
|
169
|
+
!el || el === window || el === document ? 'window' : el?.dataset?.scrollId || el?.id
|
|
170
|
+
if (!id) return
|
|
171
|
+
const pos =
|
|
172
|
+
id === 'window'
|
|
173
|
+
? { x: scrollX || 0, y: scrollY || 0 }
|
|
174
|
+
: { x: el.scrollLeft || 0, y: el.scrollTop || 0 }
|
|
175
|
+
const m = this.#areas_pos.get(this.#route_idx) || new Map()
|
|
176
|
+
m.set(String(id), pos)
|
|
177
|
+
this.#areas_pos.set(this.#route_idx, m)
|
|
178
|
+
ℹ('[🧭 scroll:set]', this.#route_idx, m)
|
|
179
|
+
}
|
|
180
|
+
|
|
124
181
|
#before_unload = ev => {
|
|
125
182
|
// persist scroll for refresh / session restore
|
|
126
183
|
try {
|
|
@@ -135,12 +192,12 @@ export default class Navgo {
|
|
|
135
192
|
const nav = this.#make_nav({
|
|
136
193
|
type: 'leave',
|
|
137
194
|
to: null,
|
|
138
|
-
|
|
195
|
+
will_unload: true,
|
|
139
196
|
event: ev,
|
|
140
197
|
})
|
|
141
|
-
this.#current.route?.[1]?.
|
|
198
|
+
this.#current.route?.[1]?.before_route_leave?.(nav)
|
|
142
199
|
if (nav.cancelled) {
|
|
143
|
-
ℹ('[🧭 navigate]', 'cancelled by
|
|
200
|
+
ℹ('[🧭 navigate]', 'cancelled by before_route_leave during unload')
|
|
144
201
|
ev.preventDefault()
|
|
145
202
|
ev.returnValue = ''
|
|
146
203
|
}
|
|
@@ -163,7 +220,7 @@ export default class Navgo {
|
|
|
163
220
|
#resolve_url_and_path(url_raw) {
|
|
164
221
|
if (url_raw[0] == '/' && !this.#base_rgx.test(url_raw)) url_raw = this.#base + url_raw
|
|
165
222
|
const url = new URL(url_raw, location.href)
|
|
166
|
-
const path = this.format(url.pathname)?.
|
|
223
|
+
const path = this.format(url.pathname).match?.(/[^?#]*/)?.[0]
|
|
167
224
|
ℹ('[🧭 resolve]', { url_in: url_raw, url: url.href, path })
|
|
168
225
|
return path ? { url, path } : null
|
|
169
226
|
}
|
|
@@ -178,7 +235,7 @@ export default class Navgo {
|
|
|
178
235
|
!a.target &&
|
|
179
236
|
!a.download &&
|
|
180
237
|
a.host === location.host &&
|
|
181
|
-
|
|
238
|
+
this.#base_rgx.test(a.pathname)
|
|
182
239
|
? { a, href }
|
|
183
240
|
: null
|
|
184
241
|
}
|
|
@@ -200,7 +257,7 @@ export default class Navgo {
|
|
|
200
257
|
/**
|
|
201
258
|
* @returns {Navigation}
|
|
202
259
|
*/
|
|
203
|
-
#make_nav({ type, from = undefined, to = undefined,
|
|
260
|
+
#make_nav({ type, from = undefined, to = undefined, will_unload = false, event = undefined }) {
|
|
204
261
|
const from_obj =
|
|
205
262
|
from !== undefined
|
|
206
263
|
? from
|
|
@@ -215,7 +272,7 @@ export default class Navgo {
|
|
|
215
272
|
type, // 'link' | 'goto' | 'popstate' | 'leave'
|
|
216
273
|
from: from_obj,
|
|
217
274
|
to,
|
|
218
|
-
|
|
275
|
+
will_unload,
|
|
219
276
|
cancelled: false,
|
|
220
277
|
event,
|
|
221
278
|
cancel() {
|
|
@@ -225,8 +282,6 @@ export default class Navgo {
|
|
|
225
282
|
}
|
|
226
283
|
|
|
227
284
|
/**
|
|
228
|
-
* Programmatic navigation that runs loaders before changing URL.
|
|
229
|
-
* Also used by popstate to unify the flow.
|
|
230
285
|
* @param {string} [url_raw]
|
|
231
286
|
* @param {{ replace?: boolean }} [opts]
|
|
232
287
|
* @param {'goto'|'link'|'popstate'} [nav_type]
|
|
@@ -253,9 +308,9 @@ export default class Navgo {
|
|
|
253
308
|
})
|
|
254
309
|
|
|
255
310
|
//
|
|
256
|
-
//
|
|
311
|
+
// before_route_leave
|
|
257
312
|
//
|
|
258
|
-
this.#current.route?.[1]?.
|
|
313
|
+
this.#current.route?.[1]?.before_route_leave?.(nav)
|
|
259
314
|
if (nav.cancelled) {
|
|
260
315
|
// use history.go to cancel the nav, and jump back to where we are
|
|
261
316
|
if (is_popstate) {
|
|
@@ -270,7 +325,7 @@ export default class Navgo {
|
|
|
270
325
|
}
|
|
271
326
|
}
|
|
272
327
|
}
|
|
273
|
-
ℹ('[🧭 goto]', 'cancelled by
|
|
328
|
+
ℹ('[🧭 goto]', 'cancelled by before_route_leave')
|
|
274
329
|
return
|
|
275
330
|
}
|
|
276
331
|
|
|
@@ -282,7 +337,6 @@ export default class Navgo {
|
|
|
282
337
|
from: nav.from?.url?.href,
|
|
283
338
|
to: url.href,
|
|
284
339
|
})
|
|
285
|
-
this.#save_scroll()
|
|
286
340
|
const hit = await this.match(path)
|
|
287
341
|
if (nav_id !== this.#nav_active) return
|
|
288
342
|
|
|
@@ -350,6 +404,7 @@ export default class Navgo {
|
|
|
350
404
|
})
|
|
351
405
|
// await so that apply_scroll is after potential async work
|
|
352
406
|
await this.#opts.after_navigate?.(nav)
|
|
407
|
+
|
|
353
408
|
if (nav_id !== this.#nav_active) return
|
|
354
409
|
ℹ('[🧭 navigate]', hit ? 'done' : 'done (404)', {
|
|
355
410
|
from: nav.from?.url?.href,
|
|
@@ -357,29 +412,46 @@ export default class Navgo {
|
|
|
357
412
|
type: nav.type,
|
|
358
413
|
idx: this.#route_idx,
|
|
359
414
|
})
|
|
415
|
+
|
|
416
|
+
// allow frameworks to flush DOM before scrolling
|
|
417
|
+
await this.#opts.tick?.()
|
|
418
|
+
|
|
360
419
|
this.#apply_scroll(nav)
|
|
361
420
|
this.#opts.url_changed?.(this.#current)
|
|
362
421
|
}
|
|
363
422
|
|
|
364
423
|
/**
|
|
365
424
|
* Shallow push — updates the URL/state but DOES NOT call handlers or loaders.
|
|
366
|
-
* URL changes, content stays put until a real nav.
|
|
367
425
|
*/
|
|
368
426
|
#commit_shallow(url, state, replace) {
|
|
369
427
|
const u = new URL(url || location.href, location.href)
|
|
370
|
-
//
|
|
371
|
-
|
|
428
|
+
// persist current entry's scroll into its state so Back after refresh restores it
|
|
429
|
+
const prev = history.state && typeof history.state == 'object' ? history.state : {}
|
|
430
|
+
history.replaceState(
|
|
431
|
+
{ ...prev, __navgo: { ...prev.__navgo, pos: { x: scrollX || 0, y: scrollY || 0 } } },
|
|
432
|
+
'',
|
|
433
|
+
location.href,
|
|
434
|
+
)
|
|
435
|
+
// also stash per-URL scroll in session storage as a fallback across reloads
|
|
436
|
+
try {
|
|
437
|
+
sessionStorage.setItem(
|
|
438
|
+
`__navgo_scroll:${location.href}`,
|
|
439
|
+
JSON.stringify({ x: scrollX || 0, y: scrollY || 0 }),
|
|
440
|
+
)
|
|
441
|
+
} catch {}
|
|
372
442
|
const idx = this.#route_idx + (replace ? 0 : 1)
|
|
373
|
-
const st = { ...state,
|
|
443
|
+
const st = { ...state, __navgo: { ...state?.__navgo, shallow: true, idx } }
|
|
374
444
|
history[(replace ? 'replace' : 'push') + 'State'](st, '', u.href)
|
|
375
|
-
ℹ('[🧭 history]', replace ? '
|
|
445
|
+
ℹ('[🧭 history]', replace ? 'replace_state(shallow)' : 'push_state(shallow)', {
|
|
376
446
|
idx,
|
|
377
447
|
href: u.href,
|
|
378
448
|
})
|
|
379
|
-
// Popstate handler checks state.
|
|
449
|
+
// Popstate handler checks state.__navgo.shallow and skips router processing
|
|
380
450
|
this.#route_idx = idx
|
|
381
|
-
// carry forward current
|
|
382
|
-
this.#
|
|
451
|
+
// carry forward current window position for the shallow entry so Forward restores correctly
|
|
452
|
+
const m = this.#areas_pos.get(idx) || new Map()
|
|
453
|
+
m.set('window', { x: scrollX || 0, y: scrollY || 0 })
|
|
454
|
+
this.#areas_pos.set(idx, m)
|
|
383
455
|
if (!replace) this.#clear_onward_history()
|
|
384
456
|
// update current URL snapshot and notify
|
|
385
457
|
this.#current.url = u
|
|
@@ -387,11 +459,11 @@ export default class Navgo {
|
|
|
387
459
|
}
|
|
388
460
|
|
|
389
461
|
/** @param {string|URL} [url] @param {any} [state] */
|
|
390
|
-
|
|
462
|
+
push_state(url, state) {
|
|
391
463
|
this.#commit_shallow(url, state, false)
|
|
392
464
|
}
|
|
393
465
|
/** @param {string|URL} [url] @param {any} [state] */
|
|
394
|
-
|
|
466
|
+
replace_state(url, state) {
|
|
395
467
|
this.#commit_shallow(url, state, true)
|
|
396
468
|
}
|
|
397
469
|
|
|
@@ -510,6 +582,8 @@ export default class Navgo {
|
|
|
510
582
|
addEventListener('click', this.#click)
|
|
511
583
|
addEventListener('beforeunload', this.#before_unload)
|
|
512
584
|
addEventListener('hashchange', this.#on_hashchange)
|
|
585
|
+
this.#scroll_handler = throttle(this.#on_scroll, 100)
|
|
586
|
+
addEventListener('scroll', this.#scroll_handler, { capture: true })
|
|
513
587
|
|
|
514
588
|
if (this.#opts.preload_on_hover) {
|
|
515
589
|
// @ts-expect-error
|
|
@@ -544,18 +618,12 @@ export default class Navgo {
|
|
|
544
618
|
removeEventListener('mousedown', this.#tap)
|
|
545
619
|
removeEventListener('beforeunload', this.#before_unload)
|
|
546
620
|
removeEventListener('hashchange', this.#on_hashchange)
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
//
|
|
550
|
-
// Scroll
|
|
551
|
-
//
|
|
552
|
-
#save_scroll() {
|
|
553
|
-
this.#scroll.set(this.#route_idx, { x: scrollX, y: scrollY })
|
|
554
|
-
ℹ('[🧭 scroll]', 'save', { idx: this.#route_idx, x: scrollX, y: scrollY })
|
|
621
|
+
removeEventListener('scroll', this.#scroll_handler, { capture: true })
|
|
622
|
+
this.#areas_pos.clear()
|
|
555
623
|
}
|
|
556
624
|
|
|
557
625
|
#clear_onward_history() {
|
|
558
|
-
for (const k of this.#
|
|
626
|
+
for (const k of this.#areas_pos.keys()) if (k > this.#route_idx) this.#areas_pos.delete(k)
|
|
559
627
|
ℹ('[🧭 scroll]', 'clear onward', { upto: this.#route_idx })
|
|
560
628
|
}
|
|
561
629
|
|
|
@@ -578,18 +646,36 @@ export default class Navgo {
|
|
|
578
646
|
// 1) On back/forward, restore saved position if available
|
|
579
647
|
if (t === 'popstate') {
|
|
580
648
|
const ev_state = ctx?.state ?? ctx?.event?.state
|
|
581
|
-
const
|
|
649
|
+
const st = ev_state?.__navgo
|
|
650
|
+
const idx = st?.idx
|
|
582
651
|
const target_idx = typeof idx === 'number' ? idx : this.#route_idx - 1
|
|
583
652
|
this.#route_idx = target_idx
|
|
584
|
-
const
|
|
653
|
+
const m = this.#areas_pos.get(target_idx)
|
|
654
|
+
let pos = st?.pos || m?.get?.('window')
|
|
655
|
+
if (!pos) {
|
|
656
|
+
try {
|
|
657
|
+
const k = `__navgo_scroll:${location.href}`
|
|
658
|
+
pos = JSON.parse(sessionStorage.getItem(k)) || null
|
|
659
|
+
sessionStorage.removeItem(k)
|
|
660
|
+
} catch {}
|
|
661
|
+
}
|
|
585
662
|
if (pos) {
|
|
586
663
|
scrollTo(pos.x, pos.y)
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
664
|
+
// re-apply on next tick to resist late reflows (e.g. images)
|
|
665
|
+
setTimeout(() => scrollTo(pos.x, pos.y), 0)
|
|
666
|
+
ℹ('[🧭 scroll]', 'restore popstate', { idx: target_idx, ...pos })
|
|
667
|
+
}
|
|
668
|
+
for (const [id, p] of m || []) {
|
|
669
|
+
if (id === 'window') continue
|
|
670
|
+
const sel = `[data-scroll-id="${CSS.escape(id)}"],` + `#${CSS.escape(id)}`
|
|
671
|
+
const el = document.querySelector(sel)
|
|
672
|
+
if (el) {
|
|
673
|
+
el.scrollTo?.(p.x, p.y)
|
|
674
|
+
el.scrollLeft = p.x
|
|
675
|
+
el.scrollTop = p.y
|
|
676
|
+
}
|
|
592
677
|
}
|
|
678
|
+
if (pos || m) return
|
|
593
679
|
}
|
|
594
680
|
// 2) If there is a hash, prefer anchor scroll
|
|
595
681
|
if (hash && this.#scroll_to_hash(hash)) {
|
|
@@ -627,13 +713,34 @@ export default class Navgo {
|
|
|
627
713
|
return true
|
|
628
714
|
}
|
|
629
715
|
},
|
|
630
|
-
|
|
716
|
+
one_of(values) {
|
|
631
717
|
const set = new Set(values)
|
|
632
718
|
return v => set.has(v)
|
|
633
719
|
},
|
|
634
720
|
}
|
|
635
721
|
}
|
|
636
722
|
|
|
723
|
+
function throttle(fn, ms) {
|
|
724
|
+
let t,
|
|
725
|
+
last = 0
|
|
726
|
+
return e => {
|
|
727
|
+
const now = Date.now()
|
|
728
|
+
if (now - last >= ms) {
|
|
729
|
+
last = now
|
|
730
|
+
fn(e)
|
|
731
|
+
} else {
|
|
732
|
+
clearTimeout(t)
|
|
733
|
+
t = setTimeout(
|
|
734
|
+
() => {
|
|
735
|
+
last = Date.now()
|
|
736
|
+
fn(e)
|
|
737
|
+
},
|
|
738
|
+
ms - (now - last),
|
|
739
|
+
)
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
|
|
637
744
|
/** @typedef {import('./index.d.ts').Options} Options */
|
|
638
745
|
/** @typedef {import('./index.d.ts').RouteTuple} RouteTuple */
|
|
639
746
|
/** @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.6",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/mustafa0x/navgo.git"
|
|
@@ -16,8 +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 run build",
|
|
20
20
|
"test": "vitest run index.test.js",
|
|
21
|
+
"test:e2e": "playwright test",
|
|
22
|
+
"start:testsite": "pnpm vite dev test/site --port 5714",
|
|
21
23
|
"types": "tsc -p test/types"
|
|
22
24
|
},
|
|
23
25
|
"files": [
|
package/readme.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
## Install
|
|
2
2
|
|
|
3
3
|
```
|
|
4
|
-
$ pnpm install --dev
|
|
4
|
+
$ pnpm install --dev navgo
|
|
5
5
|
```
|
|
6
6
|
|
|
7
7
|
## Usage
|
|
@@ -26,7 +26,7 @@ const routes = [
|
|
|
26
26
|
// load data before URL changes; result goes to after_navigate(...)
|
|
27
27
|
loaders: params => fetch('/api/admin').then(r => r.json()),
|
|
28
28
|
// per-route guard; cancel synchronously to block nav
|
|
29
|
-
|
|
29
|
+
before_route_leave(nav) {
|
|
30
30
|
if ((nav.type === 'link' || nav.type === 'nav') && !confirm('Enter admin?')) {
|
|
31
31
|
nav.cancel()
|
|
32
32
|
}
|
|
@@ -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,8 +100,10 @@ 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 — shallow `
|
|
106
|
+
- Fires on every URL change — shallow `push_state`/`replace_state`, hash changes, `popstate` shallow entries, 404s, and full navigations.
|
|
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`)
|
|
@@ -116,7 +121,7 @@ Important: Navgo only processes routes that match your `base` path.
|
|
|
116
121
|
- Run before URL changes on `link`/`nav`. Results are cached per formatted path and forwarded to `after_navigate`.
|
|
117
122
|
- validate?(params): `boolean | Promise<boolean>`
|
|
118
123
|
- Predicate called during matching. If it returns or resolves to `false`, the route is skipped.
|
|
119
|
-
-
|
|
124
|
+
- before_route_leave?(nav): `(nav: Navigation) => void`
|
|
120
125
|
- Guard called once per navigation attempt on the current route (leave). Call `nav.cancel()` synchronously to prevent navigation. For `popstate`, cancellation auto-reverts the history jump.
|
|
121
126
|
|
|
122
127
|
The `Navigation` object contains:
|
|
@@ -126,7 +131,7 @@ The `Navigation` object contains:
|
|
|
126
131
|
type: 'link' | 'nav' | 'popstate' | 'leave',
|
|
127
132
|
from: { url, params, route } | null,
|
|
128
133
|
to: { url, params, route } | null,
|
|
129
|
-
|
|
134
|
+
will_unload: boolean,
|
|
130
135
|
cancelled: boolean,
|
|
131
136
|
event?: Event,
|
|
132
137
|
cancel(): void
|
|
@@ -152,7 +157,7 @@ const routes = [
|
|
|
152
157
|
/* ... */
|
|
153
158
|
},
|
|
154
159
|
loaders: params => fetch('/api/admin/stats').then(r => r.json()),
|
|
155
|
-
|
|
160
|
+
before_route_leave(nav) {
|
|
156
161
|
if (nav.type === 'link' || nav.type === 'nav') {
|
|
157
162
|
if (!confirm('Enter admin area?')) nav.cancel()
|
|
158
163
|
}
|
|
@@ -204,7 +209,7 @@ The desired path to navigate. If it begins with `/` and does not match the confi
|
|
|
204
209
|
Type: `Object`
|
|
205
210
|
|
|
206
211
|
- replace: `Boolean` (default `false`)
|
|
207
|
-
|
|
212
|
+
- When `true`, uses `history.replaceState`; otherwise `history.pushState`.
|
|
208
213
|
|
|
209
214
|
### init()
|
|
210
215
|
|
|
@@ -233,6 +238,26 @@ Notes:
|
|
|
233
238
|
|
|
234
239
|
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
240
|
|
|
241
|
+
### Scroll Restoration (areas)
|
|
242
|
+
|
|
243
|
+
Navgo caches/restores scroll positions for the window and any scrollable element that has a stable identifier:
|
|
244
|
+
|
|
245
|
+
- Give your element either an `id` or `data-scroll-id="..."`.
|
|
246
|
+
- Navgo listens to `scroll` globally (capture) and records positions per history entry.
|
|
247
|
+
- On `popstate`, it restores matching elements before paint.
|
|
248
|
+
|
|
249
|
+
Example:
|
|
250
|
+
|
|
251
|
+
```html
|
|
252
|
+
<div id="pane" class="overflow-auto">...</div>
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
Or with a custom id:
|
|
256
|
+
|
|
257
|
+
```html
|
|
258
|
+
<div data-scroll-id="pane">...</div>
|
|
259
|
+
```
|
|
260
|
+
|
|
236
261
|
### preload(uri)
|
|
237
262
|
|
|
238
263
|
Returns: `Promise<unknown | void>`
|
|
@@ -240,13 +265,13 @@ Returns: `Promise<unknown | void>`
|
|
|
240
265
|
Preload a route's `loaders` data for a given `uri` without navigating. Concurrent calls for the same path are deduped.
|
|
241
266
|
Note: Resolves to `undefined` when the matched route has no `loaders`.
|
|
242
267
|
|
|
243
|
-
###
|
|
268
|
+
### push_state(url?, state?)
|
|
244
269
|
|
|
245
270
|
Returns: `void`
|
|
246
271
|
|
|
247
272
|
Perform a shallow history push: updates the URL/state without triggering route processing.
|
|
248
273
|
|
|
249
|
-
###
|
|
274
|
+
### replace_state(url?, state?)
|
|
250
275
|
|
|
251
276
|
Returns: `void`
|
|
252
277
|
|
|
@@ -266,9 +291,8 @@ This section explains, in detail, how navigation is processed: matching, hooks,
|
|
|
266
291
|
- `goto` — programmatic navigation via `router.goto(...)`.
|
|
267
292
|
- `popstate` — browser back/forward.
|
|
268
293
|
- `leave` — page is unloading (refresh, external navigation, tab close) via `beforeunload`.
|
|
269
|
-
- `pushState` (shallow)?
|
|
270
294
|
|
|
271
|
-
The router passes the type to your route-level `
|
|
295
|
+
The router passes the type to your route-level `before_route_leave(nav)` hook.
|
|
272
296
|
|
|
273
297
|
### Matching and Params
|
|
274
298
|
|
|
@@ -286,13 +310,15 @@ For `link` and `goto` navigations that match a route:
|
|
|
286
310
|
|
|
287
311
|
```
|
|
288
312
|
[click <a>] or [router.goto()]
|
|
289
|
-
→
|
|
313
|
+
→ before_route_leave({ type }) // per-route guard
|
|
290
314
|
→ before_navigate(nav) // app-level start
|
|
291
315
|
→ cancelled? yes → stop
|
|
292
316
|
→ no → run loaders(params) // may be value, Promise, or Promise[]
|
|
293
317
|
→ cache data by formatted path
|
|
294
318
|
→ history.push/replaceState(new URL)
|
|
295
319
|
→ after_navigate(nav)
|
|
320
|
+
→ tick()? // optional app-provided await before scroll
|
|
321
|
+
→ scroll restore/hash/top
|
|
296
322
|
```
|
|
297
323
|
|
|
298
324
|
- If a loader throws/rejects, navigation continues and `after_navigate(..., with nav.to.data = { __error })` is delivered so UI can render an error state.
|
|
@@ -300,10 +326,10 @@ For `link` and `goto` navigations that match a route:
|
|
|
300
326
|
|
|
301
327
|
### Shallow Routing
|
|
302
328
|
|
|
303
|
-
Use `
|
|
329
|
+
Use `push_state(url, state?)` or `replace_state(url, state?)` to update the URL/state without re-running routing logic.
|
|
304
330
|
|
|
305
331
|
```
|
|
306
|
-
|
|
332
|
+
push_state/replace_state (shallow)
|
|
307
333
|
→ updates history.state and URL
|
|
308
334
|
→ router does not process routing on shallow operations
|
|
309
335
|
```
|
|
@@ -323,7 +349,7 @@ Navgo manages scroll manually (sets `history.scrollRestoration = 'manual'`) and
|
|
|
323
349
|
- If the URL has a `#hash`, scroll to the matching element `id` or `[name="..."]`.
|
|
324
350
|
- Otherwise, scroll to the top `(0, 0)`.
|
|
325
351
|
- On `popstate`: restore the saved position for the target history index; if not found but there is a `#hash`, scroll to the anchor instead.
|
|
326
|
-
- Shallow `
|
|
352
|
+
- Shallow `push_state`/`replace_state` never adjust scroll (routing is skipped).
|
|
327
353
|
|
|
328
354
|
```
|
|
329
355
|
scroll flow
|
|
@@ -336,16 +362,24 @@ scroll flow
|
|
|
336
362
|
|
|
337
363
|
- `format(uri)` — normalizes a path relative to `base`. Returns `false` when `uri` is outside of `base`.
|
|
338
364
|
- `match(uri)` — returns a Promise of `{ route, params } | null` using string/RegExp patterns and validators. Awaits an async `validate(params)` if provided.
|
|
339
|
-
- `goto(uri, { replace? })` — fires route-level `
|
|
365
|
+
- `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`.
|
|
340
366
|
- `init()` — wires global listeners (`popstate`, `pushstate`, `replacestate`, click) and optional hover/tap preloading; immediately processes the current location.
|
|
341
367
|
- `destroy()` — removes listeners added by `init()`.
|
|
342
368
|
- `preload(uri)` — pre-executes a route's `loaders` for a path and caches the result; concurrent calls are deduped.
|
|
343
|
-
- `
|
|
344
|
-
- `
|
|
369
|
+
- `push_state(url?, state?)` — shallow push that updates the URL and `history.state` without route processing.
|
|
370
|
+
- `replace_state(url?, state?)` — shallow replace that updates the URL and `history.state` without route processing.
|
|
345
371
|
|
|
346
372
|
### Built-in Validators
|
|
347
373
|
|
|
348
374
|
- `Navgo.validators.int({ min?, max? })` — `true` iff the value is an integer within optional bounds.
|
|
349
|
-
- `Navgo.validators.
|
|
375
|
+
- `Navgo.validators.one_of(iterable)` — `true` iff the value is in the provided set.
|
|
350
376
|
|
|
351
377
|
Attach validators via a route tuple's `data.param_validators` to constrain matches.
|
|
378
|
+
|
|
379
|
+
# Credits
|
|
380
|
+
|
|
381
|
+
This router integrates ideas and small portions of code from these fantastic projects:
|
|
382
|
+
|
|
383
|
+
- SvelteKit — https://github.com/sveltejs/kit
|
|
384
|
+
- navaid — https://github.com/lukeed/navaid
|
|
385
|
+
- TanStack Router — https://github.com/TanStack/router
|