navgo 5.0.0 → 6.0.0-beta.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/index.d.ts +23 -16
- package/index.js +83 -53
- package/package.json +3 -2
- package/readme.md +58 -55
package/index.d.ts
CHANGED
|
@@ -3,23 +3,31 @@
|
|
|
3
3
|
* For string patterns, missing optional params are `null`.
|
|
4
4
|
* For RegExp named groups, missing groups may be `undefined`.
|
|
5
5
|
*/
|
|
6
|
-
export type
|
|
6
|
+
export type RawParam = string | null | undefined
|
|
7
|
+
export type Params = Record<string, any>
|
|
7
8
|
|
|
8
9
|
/** Built-in validator helpers shape. */
|
|
9
10
|
export interface ValidatorHelpers {
|
|
10
|
-
int(opts?: {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
11
|
+
int(opts?: { min?: number | null; max?: number | null }): (value: RawParam) => boolean
|
|
12
|
+
one_of(values: Iterable<string>): (value: RawParam) => boolean
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type ParamRule =
|
|
16
|
+
| ((value: RawParam) => boolean)
|
|
17
|
+
| { validator?: (value: RawParam) => boolean; coercer?: (value: RawParam) => any }
|
|
18
|
+
|
|
19
|
+
export interface LoaderArgs {
|
|
20
|
+
route_entry: RouteTuple
|
|
21
|
+
url: URL
|
|
22
|
+
params: Params
|
|
15
23
|
}
|
|
16
24
|
|
|
17
25
|
/** Optional per-route hooks recognized by Navgo. */
|
|
18
26
|
export interface Hooks {
|
|
19
|
-
/** Validate
|
|
20
|
-
|
|
27
|
+
/** Validate and/or coerce params (validator runs before coercer). */
|
|
28
|
+
param_rules?: Record<string, ParamRule>
|
|
21
29
|
/** Load data for a route before navigation. May return a Promise or an array of values/promises. */
|
|
22
|
-
loader?(
|
|
30
|
+
loader?(args: LoaderArgs): unknown | Promise<unknown> | Array<unknown | Promise<unknown>>
|
|
23
31
|
/** Predicate used during match(); may be async. If it returns `false`, the route is skipped. */
|
|
24
32
|
validate?(params: Params): boolean | Promise<boolean>
|
|
25
33
|
/** Route-level navigation guard, called on the current route when leaving it. Synchronous only; call `nav.cancel()` to prevent navigation. */
|
|
@@ -46,8 +54,8 @@ export interface Navigation {
|
|
|
46
54
|
cancel(): void
|
|
47
55
|
}
|
|
48
56
|
|
|
49
|
-
/** A route tuple: [pattern, data?]. The `data` may include {@link Hooks}. */
|
|
50
|
-
export type RouteTuple<T = unknown> = [pattern: string | RegExp, data
|
|
57
|
+
/** A route tuple: [pattern, data?, extra?]. The `data`/`extra` may include {@link Hooks}. */
|
|
58
|
+
export type RouteTuple<T = unknown, U = unknown> = [pattern: string | RegExp, data?: T, extra?: U]
|
|
51
59
|
|
|
52
60
|
/** Result of calling `router.match(url)` */
|
|
53
61
|
export interface MatchResult<T = unknown> {
|
|
@@ -86,11 +94,10 @@ export interface Options {
|
|
|
86
94
|
* scrolling lands on the correct elements.
|
|
87
95
|
*/
|
|
88
96
|
tick?: () => void | Promise<void>
|
|
89
|
-
/**
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
url_changed?(payload: any): void
|
|
97
|
+
/** When `false`, do not scroll to top on non-hash navigations. Default true. */
|
|
98
|
+
scroll_to_top?: boolean
|
|
99
|
+
/** When `true`, sets `aria-current="page"` on active in-app links. Default false. */
|
|
100
|
+
aria_current?: boolean
|
|
94
101
|
}
|
|
95
102
|
|
|
96
103
|
/** Navgo default export: class-based router. */
|
package/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { throttle } from 'es-toolkit'
|
|
1
2
|
import { parse } from 'regexparam'
|
|
2
3
|
import { tick } from 'svelte'
|
|
3
4
|
import { writable } from 'svelte/store'
|
|
@@ -12,8 +13,9 @@ export default class Navgo {
|
|
|
12
13
|
preload_on_hover: true,
|
|
13
14
|
before_navigate: undefined,
|
|
14
15
|
after_navigate: undefined,
|
|
15
|
-
url_changed: undefined,
|
|
16
16
|
tick,
|
|
17
|
+
scroll_to_top: true,
|
|
18
|
+
aria_current: false,
|
|
17
19
|
attach_to_window: true,
|
|
18
20
|
}
|
|
19
21
|
/** @type {Array<{ pattern: RegExp, keys: string[]|null, data: RouteTuple }>} */
|
|
@@ -105,6 +107,7 @@ export default class Navgo {
|
|
|
105
107
|
ℹ(' - [🧭 event:popstate]', 'same path+search; skip loader')
|
|
106
108
|
this.#apply_scroll(ev)
|
|
107
109
|
this.route.set(this.#current)
|
|
110
|
+
this.#update_active_links()
|
|
108
111
|
return
|
|
109
112
|
}
|
|
110
113
|
// Explicit shallow entries (pushState/replaceState) regardless of path
|
|
@@ -113,6 +116,7 @@ export default class Navgo {
|
|
|
113
116
|
ℹ(' - [🧭 event:popstate]', 'shallow entry; skip loader')
|
|
114
117
|
this.#apply_scroll(ev)
|
|
115
118
|
this.route.set(this.#current)
|
|
119
|
+
this.#update_active_links()
|
|
116
120
|
return
|
|
117
121
|
}
|
|
118
122
|
|
|
@@ -138,7 +142,7 @@ export default class Navgo {
|
|
|
138
142
|
if (pos) {
|
|
139
143
|
scrollTo(pos.x, pos.y)
|
|
140
144
|
ℹ('[🧭 scroll]', 'restore hash-back', { idx: this.#route_idx, ...pos })
|
|
141
|
-
} else {
|
|
145
|
+
} else if (this.#opts.scroll_to_top) {
|
|
142
146
|
// no saved position for previous entry — default to top
|
|
143
147
|
scrollTo(0, 0)
|
|
144
148
|
ℹ('[🧭 scroll]', 'hash-back -> top')
|
|
@@ -148,6 +152,7 @@ export default class Navgo {
|
|
|
148
152
|
// update current URL snapshot and notify
|
|
149
153
|
this.#current.url = new URL(location.href)
|
|
150
154
|
this.route.set(this.#current)
|
|
155
|
+
this.#update_active_links()
|
|
151
156
|
}
|
|
152
157
|
|
|
153
158
|
/** @type {any} */
|
|
@@ -200,7 +205,7 @@ export default class Navgo {
|
|
|
200
205
|
will_unload: true,
|
|
201
206
|
event: ev,
|
|
202
207
|
})
|
|
203
|
-
this.#current.route?.
|
|
208
|
+
this.#get_hooks(this.#current.route)?.before_route_leave?.(nav)
|
|
204
209
|
if (nav.cancelled) {
|
|
205
210
|
ℹ('[🧭 navigate]', 'cancelled by before_route_leave during unload')
|
|
206
211
|
ev.preventDefault()
|
|
@@ -245,17 +250,39 @@ export default class Navgo {
|
|
|
245
250
|
: null
|
|
246
251
|
}
|
|
247
252
|
|
|
248
|
-
#
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
+
#update_active_links() {
|
|
254
|
+
if (!this.#opts.aria_current) return
|
|
255
|
+
const cur = this.format(this.#current.url?.pathname)
|
|
256
|
+
if (!cur) return
|
|
257
|
+
for (const a of document.querySelectorAll('a[href]')) {
|
|
258
|
+
const href = a.getAttribute('href')
|
|
259
|
+
if (href[0] === '#') continue
|
|
260
|
+
const link_path = href && this.#resolve_url_and_path(href)?.path
|
|
261
|
+
if (link_path === cur) a.setAttribute('aria-current', 'page')
|
|
262
|
+
else if (a.getAttribute('aria-current') === 'page') a.removeAttribute('aria-current')
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
#get_hooks(route) {
|
|
267
|
+
if (!route) return {}
|
|
268
|
+
const a = route[1]
|
|
269
|
+
const b = route[2]
|
|
270
|
+
if (!b) return a || {}
|
|
271
|
+
const hooks = { ...(a || {}), ...b }
|
|
272
|
+
const ar = a?.param_rules
|
|
273
|
+
const br = b?.param_rules
|
|
274
|
+
if (ar || br) {
|
|
275
|
+
const out = {}
|
|
276
|
+
const norm = r => (typeof r === 'function' ? { validator: r } : r || {})
|
|
277
|
+
for (const k in ar || {}) out[k] = norm(ar[k])
|
|
278
|
+
for (const k in br || {}) out[k] = { ...out[k], ...norm(br[k]) }
|
|
279
|
+
hooks.param_rules = out
|
|
253
280
|
}
|
|
254
|
-
return
|
|
281
|
+
return hooks
|
|
255
282
|
}
|
|
256
283
|
|
|
257
284
|
async #run_loader(route, url, params) {
|
|
258
|
-
const ret_val = route
|
|
285
|
+
const ret_val = this.#get_hooks(route)?.loader?.({ route_entry: route, url, params })
|
|
259
286
|
return Array.isArray(ret_val) ? Promise.all(ret_val) : ret_val
|
|
260
287
|
}
|
|
261
288
|
|
|
@@ -316,7 +343,7 @@ export default class Navgo {
|
|
|
316
343
|
//
|
|
317
344
|
// before_route_leave
|
|
318
345
|
//
|
|
319
|
-
this.#current.route?.
|
|
346
|
+
this.#get_hooks(this.#current.route)?.before_route_leave?.(nav)
|
|
320
347
|
if (nav.cancelled) {
|
|
321
348
|
// use history.go to cancel the nav, and jump back to where we are
|
|
322
349
|
if (is_popstate) {
|
|
@@ -353,11 +380,23 @@ export default class Navgo {
|
|
|
353
380
|
let data
|
|
354
381
|
if (hit) {
|
|
355
382
|
const pre = this.#preloads.get(path)
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
383
|
+
try {
|
|
384
|
+
data =
|
|
385
|
+
pre?.data ??
|
|
386
|
+
(await (pre?.promise || this.#run_loader(hit.route, url, hit.params)))
|
|
387
|
+
} catch (e) {
|
|
388
|
+
this.#preloads.delete(path)
|
|
389
|
+
ℹ('[🧭 loader]', 'error; abort', { path, error: e })
|
|
390
|
+
if (is_popstate) {
|
|
391
|
+
const new_idx = ev_param?.state?.__navgo?.idx
|
|
392
|
+
if (new_idx != null) {
|
|
393
|
+
const delta = new_idx - this.#route_idx
|
|
394
|
+
if (delta) history.go(-delta)
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
if (nav_id === this.#nav_active) this.is_navigating.set(false)
|
|
398
|
+
return
|
|
399
|
+
}
|
|
361
400
|
this.#preloads.delete(path)
|
|
362
401
|
ℹ('[🧭 loader]', pre ? 'using preloaded data' : 'loaded', {
|
|
363
402
|
path,
|
|
@@ -425,6 +464,7 @@ export default class Navgo {
|
|
|
425
464
|
// allow frameworks to flush DOM before scrolling
|
|
426
465
|
await this.#opts.tick?.()
|
|
427
466
|
|
|
467
|
+
this.#update_active_links()
|
|
428
468
|
this.#apply_scroll(nav)
|
|
429
469
|
this.is_navigating.set(false)
|
|
430
470
|
}
|
|
@@ -465,6 +505,7 @@ export default class Navgo {
|
|
|
465
505
|
// update current URL snapshot and notify
|
|
466
506
|
this.#current.url = u
|
|
467
507
|
this.route.set(this.#current)
|
|
508
|
+
this.#update_active_links()
|
|
468
509
|
}
|
|
469
510
|
|
|
470
511
|
/** @param {string|URL} [url] @param {any} [state] */
|
|
@@ -535,15 +576,28 @@ export default class Navgo {
|
|
|
535
576
|
}
|
|
536
577
|
|
|
537
578
|
// per-route validators and optional async validate()
|
|
538
|
-
const hooks = obj.data
|
|
539
|
-
if (
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
579
|
+
const hooks = this.#get_hooks(obj.data)
|
|
580
|
+
if (hooks.param_rules) {
|
|
581
|
+
let ok = true
|
|
582
|
+
for (const k in hooks.param_rules) {
|
|
583
|
+
const param_rule = hooks.param_rules[k]
|
|
584
|
+
const param_validator =
|
|
585
|
+
typeof param_rule === 'function' ? param_rule : param_rule?.validator
|
|
586
|
+
if (typeof param_validator === 'function' && !param_validator(params[k])) {
|
|
587
|
+
ok = false
|
|
588
|
+
break
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
if (!ok) {
|
|
592
|
+
ℹ('[🧭 match]', 'skip: param_rules', { pattern: obj.data?.[0] })
|
|
593
|
+
continue
|
|
594
|
+
}
|
|
595
|
+
for (const k in hooks.param_rules) {
|
|
596
|
+
const param_rule = hooks.param_rules[k]
|
|
597
|
+
const param_coercer =
|
|
598
|
+
typeof param_rule === 'function' ? null : param_rule?.coercer
|
|
599
|
+
if (typeof param_coercer === 'function') params[k] = param_coercer(params[k])
|
|
600
|
+
}
|
|
547
601
|
}
|
|
548
602
|
if (hooks.validate && !(await hooks.validate(params))) {
|
|
549
603
|
ℹ('[🧭 match]', 'skip: validate', { pattern: obj.data?.[0] })
|
|
@@ -572,11 +626,6 @@ export default class Navgo {
|
|
|
572
626
|
return pat
|
|
573
627
|
})
|
|
574
628
|
|
|
575
|
-
// TODO: deprecated, remove later
|
|
576
|
-
this.route.subscribe(() => {
|
|
577
|
-
this.#opts.url_changed?.(this.#current)
|
|
578
|
-
})
|
|
579
|
-
|
|
580
629
|
ℹ('[🧭 init]', {
|
|
581
630
|
base: this.#base,
|
|
582
631
|
routes: this.#routes.length,
|
|
@@ -699,8 +748,10 @@ export default class Navgo {
|
|
|
699
748
|
return
|
|
700
749
|
}
|
|
701
750
|
// 3) Default: scroll to top for new navigations
|
|
702
|
-
|
|
703
|
-
|
|
751
|
+
if (this.#opts.scroll_to_top) {
|
|
752
|
+
scrollTo(0, 0)
|
|
753
|
+
ℹ('[🧭 scroll]', 'top')
|
|
754
|
+
}
|
|
704
755
|
})
|
|
705
756
|
}
|
|
706
757
|
|
|
@@ -736,27 +787,6 @@ export default class Navgo {
|
|
|
736
787
|
}
|
|
737
788
|
}
|
|
738
789
|
|
|
739
|
-
function throttle(fn, ms) {
|
|
740
|
-
let t,
|
|
741
|
-
last = 0
|
|
742
|
-
return e => {
|
|
743
|
-
const now = Date.now()
|
|
744
|
-
if (now - last >= ms) {
|
|
745
|
-
last = now
|
|
746
|
-
fn(e)
|
|
747
|
-
} else {
|
|
748
|
-
clearTimeout(t)
|
|
749
|
-
t = setTimeout(
|
|
750
|
-
() => {
|
|
751
|
-
last = Date.now()
|
|
752
|
-
fn(e)
|
|
753
|
-
},
|
|
754
|
-
ms - (now - last),
|
|
755
|
-
)
|
|
756
|
-
}
|
|
757
|
-
}
|
|
758
|
-
}
|
|
759
|
-
|
|
760
790
|
/** @typedef {import('./index.d.ts').Options} Options */
|
|
761
791
|
/** @typedef {import('./index.d.ts').RouteTuple} RouteTuple */
|
|
762
792
|
/** @typedef {import('./index.d.ts').MatchResult} MatchResult */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "navgo",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "6.0.0-beta.0",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/mustafa0x/navgo.git"
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
"build": "perl -0777 -i -pe 's/console.debug\\(...args\\)/{}/g' index.js",
|
|
19
19
|
"prepublishOnly": "pnpm run build",
|
|
20
20
|
"test": "vitest run index.test.js",
|
|
21
|
-
"test:e2e": "playwright test",
|
|
21
|
+
"test:e2e": "playwright test --project=chromium",
|
|
22
22
|
"start:testsite": "pnpm vite dev test/site --port 5714",
|
|
23
23
|
"types": "tsc -p test/types"
|
|
24
24
|
},
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
"router"
|
|
33
33
|
],
|
|
34
34
|
"dependencies": {
|
|
35
|
+
"es-toolkit": "^1.42.0",
|
|
35
36
|
"regexparam": "^3.0.0"
|
|
36
37
|
},
|
|
37
38
|
"peerDependencies": {
|
package/readme.md
CHANGED
|
@@ -8,62 +8,64 @@ $ pnpm install --dev navgo
|
|
|
8
8
|
|
|
9
9
|
```js
|
|
10
10
|
import Navgo from 'navgo'
|
|
11
|
+
import {mount} from 'svelte'
|
|
12
|
+
|
|
13
|
+
import App from './App.svelte'
|
|
14
|
+
import * as HomeRoute from './routes/Home.svelte'
|
|
15
|
+
import * as ReaderRoute from './routes/Reader.svelte'
|
|
16
|
+
import * as AccountRoute from './routes/Account.svelte'
|
|
17
|
+
import * as AdminRoute from './routes/Admin.svelte'
|
|
18
|
+
import * as DebugRoute from './routes/Debug.svelte'
|
|
11
19
|
|
|
12
|
-
// Define routes up front (strings or RegExp)
|
|
13
20
|
const routes = [
|
|
14
|
-
['/',
|
|
15
|
-
['
|
|
16
|
-
['/
|
|
17
|
-
[/articles\/(?<year>[0-9]{4})/, {}],
|
|
18
|
-
[/privacy|privacy-policy/, {}],
|
|
21
|
+
['/', HomeRoute],
|
|
22
|
+
['/:book_id', ReaderRoute],
|
|
23
|
+
['/account', AccountRoute],
|
|
19
24
|
[
|
|
20
|
-
'/admin',
|
|
25
|
+
'/admin/:id',
|
|
26
|
+
AdminRoute,
|
|
21
27
|
{
|
|
22
|
-
// constrain params
|
|
23
|
-
|
|
24
|
-
|
|
28
|
+
// constrain/coerce params
|
|
29
|
+
param_rules: {
|
|
30
|
+
id: { validator: Navgo.validators.int({ min: 1 }), coercer: Number },
|
|
25
31
|
},
|
|
26
32
|
// load data before URL changes; result goes to after_navigate(...)
|
|
27
|
-
loader: params => fetch(
|
|
33
|
+
loader: ({ params }) => fetch(`/api/admin/${params.id}`).then(r => r.json()),
|
|
28
34
|
// per-route guard; cancel synchronously to block nav
|
|
29
35
|
before_route_leave(nav) {
|
|
30
|
-
if ((nav.type === 'link' || nav.type === '
|
|
36
|
+
if ((nav.type === 'link' || nav.type === 'goto') && !confirm('Enter admin?')) {
|
|
31
37
|
nav.cancel()
|
|
32
38
|
}
|
|
33
39
|
},
|
|
34
40
|
},
|
|
35
41
|
],
|
|
36
42
|
]
|
|
43
|
+
if (window.__DEBUG__) routes.push(['/debug', DebugRoute])
|
|
44
|
+
|
|
45
|
+
const props = $state({
|
|
46
|
+
component: null,
|
|
47
|
+
route_data: null,
|
|
48
|
+
is_404: false,
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
function after_navigate(nav) {
|
|
52
|
+
props.is_404 = nav.to?.data?.__error?.status === 404
|
|
53
|
+
props.route_data = nav.to?.data ?? null
|
|
54
|
+
props.component = nav.to?.route?.[1]?.default || null
|
|
55
|
+
}
|
|
37
56
|
|
|
38
|
-
// Create router with options + callbacks
|
|
39
57
|
const router = new Navgo(routes, {
|
|
40
|
-
base: '/',
|
|
41
58
|
before_navigate(nav) {
|
|
42
59
|
// app-level hook before loader/URL update; may cancel
|
|
43
60
|
console.log('before_navigate', nav.type, '→', nav.to?.url.pathname)
|
|
44
61
|
},
|
|
45
|
-
after_navigate
|
|
46
|
-
// called after routing completes; nav.to.data holds loader result
|
|
47
|
-
if (nav.to?.data?.__error?.status === 404) {
|
|
48
|
-
console.log('404 for', nav.to.url.pathname)
|
|
49
|
-
return
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
console.log('after_navigate', nav.to?.url.pathname, nav.to?.data)
|
|
53
|
-
},
|
|
54
|
-
// let your framework flush DOM before scroll
|
|
55
|
-
// e.g. in Svelte: `import { tick } from 'svelte'`
|
|
56
|
-
tick: tick,
|
|
57
|
-
url_changed(cur) {
|
|
58
|
-
// fires on shallow/hash/popstate-shallow/404 and full navigations
|
|
59
|
-
// `cur` is the router snapshot: { url: URL, route, params }
|
|
60
|
-
console.log('url_changed', cur.url.href)
|
|
61
|
-
},
|
|
62
|
+
after_navigate,
|
|
62
63
|
})
|
|
63
64
|
|
|
64
65
|
// Long-lived router: history + <a> bindings
|
|
65
66
|
// Also immediately processes the current location
|
|
66
67
|
router.init()
|
|
68
|
+
mount(App, {target: document.body, props})
|
|
67
69
|
```
|
|
68
70
|
|
|
69
71
|
## API
|
|
@@ -74,9 +76,9 @@ Returns: `Router`
|
|
|
74
76
|
|
|
75
77
|
#### `routes`
|
|
76
78
|
|
|
77
|
-
Type: `Array<[pattern: string | RegExp, data
|
|
79
|
+
Type: `Array<[pattern: string | RegExp, data?: any, extra?: any]>`
|
|
78
80
|
|
|
79
|
-
Each route is a tuple whose first item is the pattern and whose second item is hooks (see “Route Hooks”).
|
|
81
|
+
Each route is a tuple whose first item is the pattern and whose second item is hooks (see “Route Hooks”). The optional third item is extra hooks and is merged with the second item (third wins; `param_rules` are merged by key).
|
|
80
82
|
|
|
81
83
|
Supported pattern types:
|
|
82
84
|
|
|
@@ -102,10 +104,10 @@ Notes:
|
|
|
102
104
|
- App-level hook called after routing completes (URL updated, data loaded). `nav.to.data` holds any loader data.
|
|
103
105
|
- `tick`: `() => void | Promise<void>`
|
|
104
106
|
- Awaited after `after_navigate` and before scroll handling; useful for frameworks to flush DOM so anchor/top scrolling lands correctly.
|
|
105
|
-
- `
|
|
106
|
-
-
|
|
107
|
-
|
|
108
|
-
-
|
|
107
|
+
- `scroll_to_top`: `boolean` (default `true`)
|
|
108
|
+
- When `false`, skips the default top scroll for non-hash navigations.
|
|
109
|
+
- `aria_current`: `boolean` (default `false`)
|
|
110
|
+
- When `true`, sets `aria-current="page"` on active in-app links.
|
|
109
111
|
- `preload_delay`: `number` (default `20`)
|
|
110
112
|
- Delay in ms before hover preloading triggers.
|
|
111
113
|
- `preload_on_hover`: `boolean` (default `true`)
|
|
@@ -137,10 +139,11 @@ const {route, is_navigating} = router
|
|
|
137
139
|
|
|
138
140
|
### Route Hooks
|
|
139
141
|
|
|
140
|
-
-
|
|
141
|
-
-
|
|
142
|
-
-
|
|
143
|
-
|
|
142
|
+
- param_rules?: `Record<string, ((value: string|null|undefined) => boolean) | { validator?: (value: string|null|undefined) => boolean; coercer?: (value: string|null|undefined) => any }>`
|
|
143
|
+
- Single place for param rules. If the value is a function, it is treated as a validator.
|
|
144
|
+
- Validators run on raw params; coercers run after validators and may transform params before `validate(...)`/`loader`.
|
|
145
|
+
- loader?({ params }): `unknown | Promise | Array<unknown|Promise>`
|
|
146
|
+
- Run before URL changes on `link`/`goto`. Results are cached per formatted path and forwarded to `after_navigate`.
|
|
144
147
|
- validate?(params): `boolean | Promise<boolean>`
|
|
145
148
|
- Predicate called during matching. If it returns or resolves to `false`, the route is skipped.
|
|
146
149
|
- before_route_leave?(nav): `(nav: Navigation) => void`
|
|
@@ -150,7 +153,7 @@ The `Navigation` object contains:
|
|
|
150
153
|
|
|
151
154
|
```ts
|
|
152
155
|
{
|
|
153
|
-
type: 'link' | '
|
|
156
|
+
type: 'link' | 'goto' | 'popstate' | 'leave',
|
|
154
157
|
from: { url, params, route } | null,
|
|
155
158
|
to: { url, params, route } | null,
|
|
156
159
|
will_unload: boolean,
|
|
@@ -164,7 +167,7 @@ The `Navigation` object contains:
|
|
|
164
167
|
|
|
165
168
|
- Router calls `before_navigate` on the current route (leave).
|
|
166
169
|
- Call `nav.cancel()` synchronously to cancel.
|
|
167
|
-
- For `link`/`
|
|
170
|
+
- For `link`/`goto`, it stops before URL change.
|
|
168
171
|
- For `popstate`, cancellation causes an automatic `history.go(...)` to revert to the previous index.
|
|
169
172
|
- For `leave`, cancellation triggers the native “Leave site?” dialog (behavior is browser-controlled).
|
|
170
173
|
|
|
@@ -173,15 +176,15 @@ Example:
|
|
|
173
176
|
```js
|
|
174
177
|
const routes = [
|
|
175
178
|
[
|
|
176
|
-
'/
|
|
179
|
+
'/account/:account_id',
|
|
177
180
|
{
|
|
178
|
-
|
|
179
|
-
|
|
181
|
+
param_rules: {
|
|
182
|
+
account_id: {validator: Navgo.validators.int({min: 1}), coercer: Number},
|
|
180
183
|
},
|
|
181
|
-
loader: params => fetch(
|
|
184
|
+
loader: ({params}) => fetch(`/api/account/${params.account_id}`).then(r => r.json()),
|
|
182
185
|
before_route_leave(nav) {
|
|
183
|
-
if (nav.type === 'link' || nav.type === '
|
|
184
|
-
if (!confirm('
|
|
186
|
+
if (nav.type === 'link' || nav.type === 'goto') {
|
|
187
|
+
if (!confirm('Leave account settings?')) nav.cancel()
|
|
185
188
|
}
|
|
186
189
|
},
|
|
187
190
|
},
|
|
@@ -323,7 +326,7 @@ The router passes the type to your route-level `before_route_leave(nav)` hook.
|
|
|
323
326
|
- Named params from string patterns populate `params` with `string` values; optional params that do not appear are `null`.
|
|
324
327
|
- Wildcards use the `'*'` key.
|
|
325
328
|
- RegExp named groups also populate `params`; omitted groups can be `undefined`.
|
|
326
|
-
- If `data.
|
|
329
|
+
- If `data.param_rules` is present, each `params[k]` validator runs first, then coercers run to transform params.
|
|
327
330
|
- If `data.validate(params)` returns or resolves to `false`, the route is also skipped.
|
|
328
331
|
|
|
329
332
|
### Data Flow
|
|
@@ -335,7 +338,7 @@ For `link` and `goto` navigations that match a route:
|
|
|
335
338
|
→ before_route_leave({ type }) // per-route guard
|
|
336
339
|
→ before_navigate(nav) // app-level start
|
|
337
340
|
→ cancelled? yes → stop
|
|
338
|
-
→ no → run loader(params) // may be value, Promise, or Promise[]
|
|
341
|
+
→ no → run loader({ params }) // may be value, Promise, or Promise[]
|
|
339
342
|
→ cache data by formatted path
|
|
340
343
|
→ history.push/replaceState(new URL)
|
|
341
344
|
→ after_navigate(nav)
|
|
@@ -343,8 +346,8 @@ For `link` and `goto` navigations that match a route:
|
|
|
343
346
|
→ scroll restore/hash/top
|
|
344
347
|
```
|
|
345
348
|
|
|
346
|
-
- If a loader throws/rejects, navigation
|
|
347
|
-
- For `popstate`, the route's `loader` runs before completion so content matches the target entry; this improves scroll restoration.
|
|
349
|
+
- If a loader throws/rejects, navigation aborts (no URL/history change, no `after_navigate`).
|
|
350
|
+
- For `popstate`, the route's `loader` runs before completion so content matches the target entry; this improves scroll restoration. If the loader throws, the popstate is reverted.
|
|
348
351
|
|
|
349
352
|
### Shallow Routing
|
|
350
353
|
|
|
@@ -367,7 +370,7 @@ To enable `popstate` cancellation, Navgo stores a monotonic `idx` in `history.st
|
|
|
367
370
|
Navgo manages scroll manually (sets `history.scrollRestoration = 'manual'`) and applies SvelteKit-like behavior:
|
|
368
371
|
|
|
369
372
|
- Saves the current scroll position for the active history index.
|
|
370
|
-
- On `link`/`
|
|
373
|
+
- On `link`/`goto` (after route commit):
|
|
371
374
|
- If the URL has a `#hash`, scroll to the matching element `id` or `[name="..."]`.
|
|
372
375
|
- Otherwise, scroll to the top `(0, 0)`.
|
|
373
376
|
- On `popstate`: restore the saved position for the target history index; if not found but there is a `#hash`, scroll to the anchor instead.
|
|
@@ -396,7 +399,7 @@ scroll flow
|
|
|
396
399
|
- `Navgo.validators.int({ min?, max? })` -- `true` iff the value is an integer within optional bounds.
|
|
397
400
|
- `Navgo.validators.one_of(iterable)` -- `true` iff the value is in the provided set.
|
|
398
401
|
|
|
399
|
-
Attach validators via a route tuple's `data.
|
|
402
|
+
Attach validators via a route tuple's `data.param_rules` rules.
|
|
400
403
|
|
|
401
404
|
# Credits
|
|
402
405
|
|