@everystate/router 1.0.0 → 1.0.2
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/LICENSE +21 -0
- package/README.md +77 -21
- package/index.d.ts +79 -0
- package/index.js +2 -3
- package/package.json +9 -5
- package/router.js +364 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ajdin Imsirovic
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# @everystate/router
|
|
2
2
|
|
|
3
|
-
**SPA router for EveryState: routing is just state
|
|
3
|
+
**SPA router for EveryState: routing is just state.**
|
|
4
4
|
|
|
5
|
-
Treat routing as state. Routes, params, and navigation history live in your EveryState store.
|
|
5
|
+
Treat routing as state. Routes, params, query strings, and navigation history live in your EveryState store at `ui.route.*`.
|
|
6
6
|
|
|
7
7
|
## Installation
|
|
8
8
|
|
|
@@ -13,37 +13,93 @@ npm install @everystate/router @everystate/core
|
|
|
13
13
|
## Quick Start
|
|
14
14
|
|
|
15
15
|
```js
|
|
16
|
-
import {
|
|
16
|
+
import { createEveryState } from '@everystate/core';
|
|
17
17
|
import { createRouter } from '@everystate/router';
|
|
18
18
|
|
|
19
|
-
const store =
|
|
19
|
+
const store = createEveryState({});
|
|
20
20
|
|
|
21
|
-
const router = createRouter(
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
'/
|
|
25
|
-
'/
|
|
26
|
-
}
|
|
27
|
-
|
|
21
|
+
const router = createRouter({
|
|
22
|
+
store,
|
|
23
|
+
routes: [
|
|
24
|
+
{ path: '/', view: 'home', component: HomeView },
|
|
25
|
+
{ path: '/about', view: 'about', component: AboutView },
|
|
26
|
+
{ path: '/users/:id', view: 'user', component: UserView },
|
|
27
|
+
],
|
|
28
|
+
fallback: { view: '404', component: NotFoundView },
|
|
29
|
+
}).start();
|
|
28
30
|
|
|
29
31
|
// Subscribe to route changes
|
|
30
|
-
store.subscribe('
|
|
31
|
-
console.log('
|
|
32
|
+
store.subscribe('ui.route.view', (view) => {
|
|
33
|
+
console.log('View changed:', view);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// Navigate programmatically
|
|
37
|
+
router.navigate('/users/123');
|
|
38
|
+
|
|
39
|
+
// Or navigate from anywhere via the store (no router import needed)
|
|
40
|
+
store.set('ui.route.go', '/about');
|
|
41
|
+
store.set('ui.route.go', { path: '/users/1', search: '?tab=posts' });
|
|
42
|
+
store.set('ui.route.go', { query: { tab: 'posts' } }); // patch query only
|
|
43
|
+
|
|
44
|
+
// Access route state
|
|
45
|
+
store.get('ui.route.view'); // 'user'
|
|
46
|
+
store.get('ui.route.path'); // '/users/123'
|
|
47
|
+
store.get('ui.route.params'); // { id: '123' }
|
|
48
|
+
store.get('ui.route.query'); // { tab: 'posts' }
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Route Config
|
|
52
|
+
|
|
53
|
+
```js
|
|
54
|
+
createRouter({
|
|
55
|
+
store, // EveryState store instance
|
|
56
|
+
routes: [{ path, view, component }], // route definitions
|
|
57
|
+
fallback: { view, component }, // 404 fallback (optional)
|
|
58
|
+
rootSelector: '[data-route-root]', // mount point (default)
|
|
59
|
+
linkSelector: 'a[data-link]', // intercepted links (default)
|
|
60
|
+
navSelector: 'nav a[data-link]', // auto .active class (default)
|
|
61
|
+
debug: false, // console.debug logging
|
|
32
62
|
});
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Component Boot Protocol
|
|
33
66
|
|
|
34
|
-
|
|
35
|
-
router.navigate('/user/123');
|
|
67
|
+
Each route's `component` must expose a `boot()` function:
|
|
36
68
|
|
|
37
|
-
|
|
38
|
-
const
|
|
69
|
+
```js
|
|
70
|
+
export const HomeView = {
|
|
71
|
+
async boot({ store, el, signal, params }) {
|
|
72
|
+
el.innerHTML = '<h1>Home</h1>';
|
|
73
|
+
// Return an unboot function for cleanup
|
|
74
|
+
return () => { el.innerHTML = ''; };
|
|
75
|
+
}
|
|
76
|
+
};
|
|
39
77
|
```
|
|
40
78
|
|
|
41
79
|
## Features
|
|
42
80
|
|
|
43
|
-
- **Routes as state**
|
|
44
|
-
- **Params as state** URL params at `
|
|
45
|
-
- **
|
|
46
|
-
- **
|
|
81
|
+
- **Routes as state** - current route at `ui.route.view`, `ui.route.path`
|
|
82
|
+
- **Params as state** - URL params at `ui.route.params.*`
|
|
83
|
+
- **Query as state** - query string at `ui.route.query.*`
|
|
84
|
+
- **Store-driven navigation** - `store.set('ui.route.go', '/path')` from anywhere
|
|
85
|
+
- **Transition state** - `ui.route.transitioning` flag for loading indicators
|
|
86
|
+
- **History integration** - browser back/forward with scroll position restore
|
|
87
|
+
- **Base path support** - auto-detects `<base href>` for subdirectory deploys
|
|
88
|
+
- **Nav active state** - auto-toggles `.active` class on `nav a[data-link]` elements
|
|
89
|
+
- **Abort controller** - cancels in-flight boots when navigation is superseded
|
|
90
|
+
- **Focus management** - moves focus to route root for accessibility
|
|
91
|
+
- **Framework-agnostic** - works with any view layer or vanilla DOM
|
|
92
|
+
|
|
93
|
+
## API
|
|
94
|
+
|
|
95
|
+
| Method | Description |
|
|
96
|
+
|---|---|
|
|
97
|
+
| `router.start()` | Begin listening for clicks and popstate events |
|
|
98
|
+
| `router.stop()` | Remove all listeners and clean up |
|
|
99
|
+
| `router.navigate(path, opts?)` | Navigate to a path (`{ replace, search, restoreScroll }`) |
|
|
100
|
+
| `router.navigateQuery(patch, opts?)` | Patch query params without changing path |
|
|
101
|
+
| `router.navigatePath(path, opts?)` | Navigate keeping current search string |
|
|
102
|
+
| `router.getCurrent()` | Returns `{ view, path, search }` |
|
|
47
103
|
|
|
48
104
|
## License
|
|
49
105
|
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @everystate/router
|
|
3
|
+
*
|
|
4
|
+
* SPA router for EveryState stores. Routing is just state.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) 2026 Ajdin Imsirovic. MIT License.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface RouteDefinition {
|
|
10
|
+
/** URL pattern, e.g. '/users/:id' */
|
|
11
|
+
path: string;
|
|
12
|
+
/** View key written to ui.route.view */
|
|
13
|
+
view: string;
|
|
14
|
+
/** Component with a boot({ store, el, signal, params }) method */
|
|
15
|
+
component?: { boot: (ctx: BootContext) => Promise<(() => void) | void> | (() => void) | void };
|
|
16
|
+
[key: string]: any;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface BootContext {
|
|
20
|
+
store: any;
|
|
21
|
+
el: HTMLElement;
|
|
22
|
+
signal: AbortSignal;
|
|
23
|
+
params: Record<string, string>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface NavigateOptions {
|
|
27
|
+
replace?: boolean;
|
|
28
|
+
search?: string;
|
|
29
|
+
restoreScroll?: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface RouterConfig {
|
|
33
|
+
/** Route definitions */
|
|
34
|
+
routes?: RouteDefinition[];
|
|
35
|
+
/** EveryState store instance */
|
|
36
|
+
store?: any;
|
|
37
|
+
/** CSS selector for the route mount point (default: '[data-route-root]') */
|
|
38
|
+
rootSelector?: string;
|
|
39
|
+
/** Fallback route when nothing matches */
|
|
40
|
+
fallback?: Partial<RouteDefinition> | null;
|
|
41
|
+
/** Enable debug logging (default: false) */
|
|
42
|
+
debug?: boolean;
|
|
43
|
+
/** CSS selector for intercepted links (default: 'a[data-link]') */
|
|
44
|
+
linkSelector?: string;
|
|
45
|
+
/** CSS selector for nav links that get .active class (default: 'nav a[data-link]') */
|
|
46
|
+
navSelector?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface Router {
|
|
50
|
+
/** Navigate to a pathname */
|
|
51
|
+
navigate(pathname: string, opts?: NavigateOptions): Promise<void>;
|
|
52
|
+
/** Patch query parameters without changing path */
|
|
53
|
+
navigateQuery(patch?: Record<string, string | null | undefined>, opts?: { replace?: boolean }): Promise<void>;
|
|
54
|
+
/** Navigate to a new path, keeping the current search string */
|
|
55
|
+
navigatePath(path: string, opts?: { replace?: boolean }): Promise<void>;
|
|
56
|
+
/** Start listening for click and popstate events, navigate to current URL */
|
|
57
|
+
start(): Router;
|
|
58
|
+
/** Remove all listeners and clean up */
|
|
59
|
+
stop(): Router;
|
|
60
|
+
/** Get the current route state */
|
|
61
|
+
getCurrent(): { view: string | null; path: string | null; search: string };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Create a SPA router bound to an EveryState store.
|
|
66
|
+
*
|
|
67
|
+
* Store paths written on navigation:
|
|
68
|
+
* ui.route.view - current view key
|
|
69
|
+
* ui.route.path - current pathname
|
|
70
|
+
* ui.route.params - extracted URL params
|
|
71
|
+
* ui.route.query - parsed query string
|
|
72
|
+
* ui.route.transitioning - true during view transitions
|
|
73
|
+
*
|
|
74
|
+
* Store-driven navigation (no router import needed):
|
|
75
|
+
* store.set('ui.route.go', '/about')
|
|
76
|
+
* store.set('ui.route.go', { path: '/users/1', search: '?tab=posts' })
|
|
77
|
+
* store.set('ui.route.go', { query: { tab: 'posts' } })
|
|
78
|
+
*/
|
|
79
|
+
export function createRouter(config: RouterConfig): Router;
|
package/index.js
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @everystate/router
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* Re-exports all functionality from the underlying @uistate/router package
|
|
4
|
+
* SPA router for EveryState stores. Routing is just state.
|
|
6
5
|
*/
|
|
7
6
|
|
|
8
|
-
export
|
|
7
|
+
export { createRouter } from './router.js';
|
package/package.json
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystate/router",
|
|
3
|
-
"version": "1.0.
|
|
4
|
-
"description": "EveryState Router: SPA router for
|
|
3
|
+
"version": "1.0.2",
|
|
4
|
+
"description": "EveryState Router: SPA router for EveryState stores. Routing is just state",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
7
|
+
"types": "index.d.ts",
|
|
7
8
|
"keywords": [
|
|
8
9
|
"everystate",
|
|
9
10
|
"router",
|
|
@@ -23,7 +24,10 @@
|
|
|
23
24
|
"type": "git",
|
|
24
25
|
"url": "https://github.com/ImsirovicAjdin/everystate-router"
|
|
25
26
|
},
|
|
26
|
-
"
|
|
27
|
-
"
|
|
28
|
-
|
|
27
|
+
"files": [
|
|
28
|
+
"index.js",
|
|
29
|
+
"router.js",
|
|
30
|
+
"index.d.ts",
|
|
31
|
+
"README.md"
|
|
32
|
+
]
|
|
29
33
|
}
|
package/router.js
ADDED
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
// @everystate/router: SPA router factory for EveryState stores
|
|
2
|
+
// Routing is just state: navigate() writes to store paths, components subscribe.
|
|
3
|
+
//
|
|
4
|
+
// Copyright (c) 2026 Ajdin Imsirovic. MIT License.
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Compile a route pattern like '/users/:id/posts/:postId' into a matcher.
|
|
8
|
+
* Returns { regex, paramNames } for extraction.
|
|
9
|
+
*/
|
|
10
|
+
function compilePattern(pattern) {
|
|
11
|
+
const paramNames = [];
|
|
12
|
+
const parts = pattern.split(/:([a-zA-Z_][a-zA-Z0-9_]*)/);
|
|
13
|
+
const regexStr = parts
|
|
14
|
+
.map((part, i) => {
|
|
15
|
+
if (i % 2 === 1) {
|
|
16
|
+
paramNames.push(part);
|
|
17
|
+
return '([^/]+)';
|
|
18
|
+
}
|
|
19
|
+
return part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
20
|
+
})
|
|
21
|
+
.join('');
|
|
22
|
+
return { regex: new RegExp('^' + regexStr + '$'), paramNames };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Create a SPA router bound to an EveryState store.
|
|
27
|
+
*
|
|
28
|
+
* @param {Object} config
|
|
29
|
+
* @param {Array} config.routes - [{ path: '/users/:id', view: 'user', component: UserView }]
|
|
30
|
+
* @param {Object} [config.store] - EveryState store instance
|
|
31
|
+
* @param {string} [config.rootSelector='[data-route-root]'] - Root element for view mounting
|
|
32
|
+
* @param {Object} [config.fallback] - Fallback route when nothing matches
|
|
33
|
+
* @param {boolean} [config.debug=false]
|
|
34
|
+
* @param {string} [config.linkSelector='a[data-link]'] - Selector for intercepted links
|
|
35
|
+
* @param {string} [config.navSelector='nav a[data-link]'] - Selector for nav links to toggle .active class
|
|
36
|
+
*
|
|
37
|
+
* Store-driven navigation (requires store):
|
|
38
|
+
* Any code with store access can navigate without importing the router:
|
|
39
|
+
* - store.set('ui.route.go', '/about')
|
|
40
|
+
* - store.set('ui.route.go', { path: '/users/1', search: '?tab=posts' })
|
|
41
|
+
* - store.set('ui.route.go', { query: { tab: 'posts' } }) // patch query only
|
|
42
|
+
*/
|
|
43
|
+
export function createRouter(config) {
|
|
44
|
+
const {
|
|
45
|
+
routes = [],
|
|
46
|
+
store,
|
|
47
|
+
rootSelector = '[data-route-root]',
|
|
48
|
+
fallback = null,
|
|
49
|
+
debug = false,
|
|
50
|
+
linkSelector = 'a[data-link]',
|
|
51
|
+
navSelector = 'nav a[data-link]',
|
|
52
|
+
} = config;
|
|
53
|
+
|
|
54
|
+
// Pre-compile route patterns
|
|
55
|
+
const compiled = routes.map(route => ({
|
|
56
|
+
...route,
|
|
57
|
+
...compilePattern(route.path),
|
|
58
|
+
}));
|
|
59
|
+
|
|
60
|
+
const compiledFallback = fallback
|
|
61
|
+
? { ...fallback, ...compilePattern(fallback.path || '/*'), params: {} }
|
|
62
|
+
: null;
|
|
63
|
+
|
|
64
|
+
// Detect base path from <base href> if present
|
|
65
|
+
const BASE_PATH = (() => {
|
|
66
|
+
const b = document.querySelector('base[href]');
|
|
67
|
+
if (!b) return '';
|
|
68
|
+
try {
|
|
69
|
+
const u = new URL(b.getAttribute('href'), location.href);
|
|
70
|
+
let p = u.pathname;
|
|
71
|
+
if (p.length > 1 && p.endsWith('/')) p = p.slice(0, -1);
|
|
72
|
+
return p;
|
|
73
|
+
} catch { return ''; }
|
|
74
|
+
})();
|
|
75
|
+
|
|
76
|
+
function stripBase(pathname) {
|
|
77
|
+
if (BASE_PATH && pathname.startsWith(BASE_PATH)) {
|
|
78
|
+
const rest = pathname.slice(BASE_PATH.length) || '/';
|
|
79
|
+
return rest.startsWith('/') ? rest : ('/' + rest);
|
|
80
|
+
}
|
|
81
|
+
return pathname;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function withBase(pathname) {
|
|
85
|
+
if (!BASE_PATH) return pathname;
|
|
86
|
+
if (pathname === '/') return BASE_PATH || '/';
|
|
87
|
+
return BASE_PATH + (pathname.startsWith('/') ? '' : '/') + pathname;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function normalizePath(p) {
|
|
91
|
+
if (!p) return '/';
|
|
92
|
+
if (p[0] !== '/') p = '/' + p;
|
|
93
|
+
if (p === '/index.html') return '/';
|
|
94
|
+
if (p.length > 1 && p.endsWith('/')) p = p.slice(0, -1);
|
|
95
|
+
return p;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function resolve(pathname) {
|
|
99
|
+
const p = normalizePath(pathname);
|
|
100
|
+
for (const route of compiled) {
|
|
101
|
+
const match = p.match(route.regex);
|
|
102
|
+
if (match) {
|
|
103
|
+
const params = {};
|
|
104
|
+
route.paramNames.forEach((name, i) => {
|
|
105
|
+
params[name] = decodeURIComponent(match[i + 1]);
|
|
106
|
+
});
|
|
107
|
+
return { path: route.path, view: route.view, component: route.component, params };
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (compiledFallback) return { ...compiledFallback, params: {} };
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function getRoot() {
|
|
115
|
+
const el = document.querySelector(rootSelector);
|
|
116
|
+
if (!el) throw new Error('[router] Route root not found: ' + rootSelector);
|
|
117
|
+
return el;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function log(...args) {
|
|
121
|
+
if (debug) console.debug('[router]', ...args);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function setActiveNav(pathname) {
|
|
125
|
+
document.querySelectorAll(navSelector).forEach(a => {
|
|
126
|
+
const url = new URL(a.getAttribute('href'), location.href);
|
|
127
|
+
const linkPath = normalizePath(stripBase(url.pathname));
|
|
128
|
+
const here = normalizePath(pathname);
|
|
129
|
+
const isExact = linkPath === here;
|
|
130
|
+
const isParent = !isExact && linkPath !== '/' && here.startsWith(linkPath);
|
|
131
|
+
const active = isExact || isParent;
|
|
132
|
+
a.classList.toggle('active', active);
|
|
133
|
+
if (isExact) a.setAttribute('aria-current', 'page');
|
|
134
|
+
else a.removeAttribute('aria-current');
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Internal state
|
|
139
|
+
let current = { viewKey: null, unboot: null, path: null, search: '' };
|
|
140
|
+
let navController = null;
|
|
141
|
+
const scrollPositions = new Map();
|
|
142
|
+
history.scrollRestoration = 'manual';
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Navigate to a pathname.
|
|
146
|
+
* @param {string} pathname
|
|
147
|
+
* @param {Object} [opts]
|
|
148
|
+
* @param {boolean} [opts.replace=false]
|
|
149
|
+
* @param {string} [opts.search='']
|
|
150
|
+
* @param {boolean} [opts.restoreScroll=false]
|
|
151
|
+
*/
|
|
152
|
+
async function navigate(pathname, { replace = false, search = '', restoreScroll = false } = {}) {
|
|
153
|
+
const root = getRoot();
|
|
154
|
+
const appPath = normalizePath(stripBase(pathname));
|
|
155
|
+
const resolved = resolve(appPath);
|
|
156
|
+
|
|
157
|
+
if (!resolved) {
|
|
158
|
+
log('no route found for:', appPath);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const viewKey = resolved.view;
|
|
163
|
+
const component = resolved.component;
|
|
164
|
+
const searchStr = search && search.startsWith('?') ? search : (search ? ('?' + search) : '');
|
|
165
|
+
|
|
166
|
+
log('navigate', { from: current.path, to: appPath, view: viewKey, params: resolved.params });
|
|
167
|
+
|
|
168
|
+
// Same-route no-op guard
|
|
169
|
+
if (current.path === appPath && current.search === searchStr) {
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Abort in-flight boot
|
|
174
|
+
if (navController) navController.abort();
|
|
175
|
+
navController = new AbortController();
|
|
176
|
+
const { signal } = navController;
|
|
177
|
+
|
|
178
|
+
// Transition start
|
|
179
|
+
const html = document.documentElement;
|
|
180
|
+
html.setAttribute('data-transitioning', 'on');
|
|
181
|
+
if (store) {
|
|
182
|
+
try { store.set('ui.route.transitioning', true); } catch {}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Save scroll position for current route
|
|
186
|
+
if (current.path) {
|
|
187
|
+
scrollPositions.set(current.path, { x: scrollX, y: scrollY });
|
|
188
|
+
if (scrollPositions.size > 50) scrollPositions.delete(scrollPositions.keys().next().value);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Unboot previous view
|
|
192
|
+
if (typeof current.unboot === 'function') {
|
|
193
|
+
try { await current.unboot(); } catch {}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Clear root
|
|
197
|
+
root.replaceChildren();
|
|
198
|
+
|
|
199
|
+
// Boot new view
|
|
200
|
+
let unboot = null;
|
|
201
|
+
if (component && typeof component.boot === 'function') {
|
|
202
|
+
unboot = await component.boot({ store, el: root, signal, params: resolved.params });
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Guard: if navigation was superseded during boot, bail out
|
|
206
|
+
if (signal.aborted) return;
|
|
207
|
+
|
|
208
|
+
const prevViewKey = current.viewKey;
|
|
209
|
+
current = { viewKey, unboot, path: appPath, search: searchStr };
|
|
210
|
+
|
|
211
|
+
// Parse query params
|
|
212
|
+
const fullUrl = new URL(location.origin + withBase(appPath) + searchStr);
|
|
213
|
+
const query = {};
|
|
214
|
+
fullUrl.searchParams.forEach((v, k) => { query[k] = v; });
|
|
215
|
+
|
|
216
|
+
// Update store with route state + end transition atomically
|
|
217
|
+
if (store) {
|
|
218
|
+
try {
|
|
219
|
+
store.setMany({
|
|
220
|
+
'ui.route.view': viewKey,
|
|
221
|
+
'ui.route.path': appPath,
|
|
222
|
+
'ui.route.params': resolved.params || {},
|
|
223
|
+
'ui.route.query': query,
|
|
224
|
+
'ui.route.transitioning': false,
|
|
225
|
+
});
|
|
226
|
+
} catch {}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Update browser history
|
|
230
|
+
const useReplace = replace;
|
|
231
|
+
if (useReplace) history.replaceState({}, '', withBase(appPath) + searchStr);
|
|
232
|
+
else history.pushState({}, '', withBase(appPath) + searchStr);
|
|
233
|
+
|
|
234
|
+
// Set view attribute on <html> for CSS hooks
|
|
235
|
+
html.setAttribute('data-view', viewKey);
|
|
236
|
+
html.setAttribute('data-transitioning', 'off');
|
|
237
|
+
|
|
238
|
+
// Update nav active state
|
|
239
|
+
setActiveNav(appPath);
|
|
240
|
+
|
|
241
|
+
// Focus management (accessibility)
|
|
242
|
+
if (!root.hasAttribute('tabindex')) root.setAttribute('tabindex', '-1');
|
|
243
|
+
try { root.focus({ preventScroll: true }); } catch {}
|
|
244
|
+
|
|
245
|
+
// Scroll
|
|
246
|
+
if (restoreScroll) {
|
|
247
|
+
const pos = scrollPositions.get(appPath);
|
|
248
|
+
if (pos) scrollTo(pos.x, pos.y);
|
|
249
|
+
} else {
|
|
250
|
+
scrollTo(0, 0);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
log('routed', { view: viewKey, path: appPath, params: resolved.params, query });
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Patch query parameters without changing the path.
|
|
258
|
+
* Pass null/undefined/'' as a value to remove a key.
|
|
259
|
+
*/
|
|
260
|
+
function navigateQuery(patch = {}, { replace = true } = {}) {
|
|
261
|
+
const params = new URLSearchParams(current.search?.replace(/^\?/, '') || '');
|
|
262
|
+
for (const [k, v] of Object.entries(patch)) {
|
|
263
|
+
if (v === null || v === undefined || v === '') params.delete(k);
|
|
264
|
+
else params.set(k, String(v));
|
|
265
|
+
}
|
|
266
|
+
const searchStr = params.toString();
|
|
267
|
+
const prefixed = searchStr ? ('?' + searchStr) : '';
|
|
268
|
+
const path = current.path || normalizePath(stripBase(location.pathname));
|
|
269
|
+
return navigate(path, { search: prefixed, replace });
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Navigate to a new path, keeping the current search string.
|
|
274
|
+
*/
|
|
275
|
+
function navigatePath(path, { replace = true } = {}) {
|
|
276
|
+
const appPath = normalizePath(stripBase(path));
|
|
277
|
+
const searchStr = current.search || '';
|
|
278
|
+
return navigate(appPath, { search: searchStr, replace });
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Event handlers
|
|
282
|
+
function onClick(e) {
|
|
283
|
+
const a = e.target.closest(linkSelector);
|
|
284
|
+
if (!a) return;
|
|
285
|
+
if (e.defaultPrevented) return;
|
|
286
|
+
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) return;
|
|
287
|
+
const href = a.getAttribute('href');
|
|
288
|
+
if (!href) return;
|
|
289
|
+
const url = new URL(href, location.href);
|
|
290
|
+
if (url.origin !== location.origin) return;
|
|
291
|
+
e.preventDefault();
|
|
292
|
+
log('click', { href, text: a.textContent.trim() });
|
|
293
|
+
navigate(url.pathname, { search: url.search }).catch(() => {});
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function onPop() {
|
|
297
|
+
navigate(location.pathname, {
|
|
298
|
+
replace: true,
|
|
299
|
+
search: location.search,
|
|
300
|
+
restoreScroll: true,
|
|
301
|
+
}).catch(() => {});
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Store-driven navigation: write ui.route.go to navigate from anywhere
|
|
305
|
+
let unsubGo = null;
|
|
306
|
+
let processingGo = false;
|
|
307
|
+
if (store) {
|
|
308
|
+
unsubGo = store.subscribe('ui.route.go', (value) => {
|
|
309
|
+
if (processingGo || !value) return;
|
|
310
|
+
processingGo = true;
|
|
311
|
+
try { store.set('ui.route.go', null); } catch {}
|
|
312
|
+
processingGo = false;
|
|
313
|
+
|
|
314
|
+
if (typeof value === 'string') {
|
|
315
|
+
navigate(value).catch(() => {});
|
|
316
|
+
} else if (typeof value === 'object') {
|
|
317
|
+
if (!value.path && value.query) {
|
|
318
|
+
navigateQuery(value.query, { replace: value.replace ?? true }).catch(() => {});
|
|
319
|
+
} else {
|
|
320
|
+
navigate(value.path || '/', {
|
|
321
|
+
search: value.search || '',
|
|
322
|
+
replace: value.replace || false,
|
|
323
|
+
}).catch(() => {});
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Public API
|
|
330
|
+
return {
|
|
331
|
+
navigate,
|
|
332
|
+
navigateQuery,
|
|
333
|
+
navigatePath,
|
|
334
|
+
|
|
335
|
+
start() {
|
|
336
|
+
window.addEventListener('click', onClick);
|
|
337
|
+
window.addEventListener('popstate', onPop);
|
|
338
|
+
navigate(location.pathname, {
|
|
339
|
+
replace: true,
|
|
340
|
+
search: location.search,
|
|
341
|
+
restoreScroll: true,
|
|
342
|
+
});
|
|
343
|
+
return this;
|
|
344
|
+
},
|
|
345
|
+
|
|
346
|
+
stop() {
|
|
347
|
+
window.removeEventListener('click', onClick);
|
|
348
|
+
window.removeEventListener('popstate', onPop);
|
|
349
|
+
if (unsubGo) { unsubGo(); unsubGo = null; }
|
|
350
|
+
if (typeof current.unboot === 'function') {
|
|
351
|
+
try { Promise.resolve(current.unboot()).catch(() => {}); } catch {}
|
|
352
|
+
}
|
|
353
|
+
return this;
|
|
354
|
+
},
|
|
355
|
+
|
|
356
|
+
getCurrent() {
|
|
357
|
+
return {
|
|
358
|
+
view: current.viewKey,
|
|
359
|
+
path: current.path,
|
|
360
|
+
search: current.search,
|
|
361
|
+
};
|
|
362
|
+
},
|
|
363
|
+
};
|
|
364
|
+
}
|