@uistate/router 1.0.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/LICENSE +21 -0
- package/README.md +245 -0
- package/index.js +1 -0
- package/package.json +42 -0
- package/router.js +365 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 UIstate
|
|
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
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
# @uistate/router
|
|
2
|
+
|
|
3
|
+
SPA router for EventState stores. Routing is just state.
|
|
4
|
+
|
|
5
|
+
`navigate()` writes to store paths. Components subscribe. No framework required.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install @uistate/router
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Quick Start
|
|
14
|
+
|
|
15
|
+
```js
|
|
16
|
+
import { createEventState } from '@uistate/core';
|
|
17
|
+
import { createRouter } from '@uistate/router';
|
|
18
|
+
|
|
19
|
+
const store = createEventState({ state: {} });
|
|
20
|
+
|
|
21
|
+
const router = createRouter({
|
|
22
|
+
routes: [
|
|
23
|
+
{ path: '/', view: 'home', component: HomeView },
|
|
24
|
+
{ path: '/users', view: 'users', component: UsersView },
|
|
25
|
+
{ path: '/users/:id', view: 'user', component: UserView },
|
|
26
|
+
{ path: '/users/:id/posts/:postId', view: 'post', component: PostView },
|
|
27
|
+
],
|
|
28
|
+
store,
|
|
29
|
+
fallback: { path: '/*', view: '404', component: NotFoundView },
|
|
30
|
+
debug: true,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
router.start();
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## How It Works
|
|
37
|
+
|
|
38
|
+
Every navigation writes to the store:
|
|
39
|
+
|
|
40
|
+
| Store Path | Value |
|
|
41
|
+
|---|---|
|
|
42
|
+
| `ui.route.view` | The matched `view` string (e.g. `'user'`) |
|
|
43
|
+
| `ui.route.path` | The normalized path (e.g. `'/users/42'`) |
|
|
44
|
+
| `ui.route.params` | Extracted params (e.g. `{ id: '42' }`) |
|
|
45
|
+
| `ui.route.query` | Parsed query params (e.g. `{ tab: 'posts' }`) |
|
|
46
|
+
| `ui.route.transitioning` | `true` during navigation, `false` after |
|
|
47
|
+
|
|
48
|
+
Your components subscribe to these paths like any other state:
|
|
49
|
+
|
|
50
|
+
```js
|
|
51
|
+
store.subscribe('ui.route.view', (view) => {
|
|
52
|
+
console.log('View changed to:', view);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
store.subscribe('ui.route.params', (params) => {
|
|
56
|
+
console.log('Route params:', params);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// Wildcard: react to any route change
|
|
60
|
+
store.subscribe('ui.route.*', ({ path, value }) => {
|
|
61
|
+
console.log('Route state changed:', path, value);
|
|
62
|
+
});
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Route Patterns
|
|
66
|
+
|
|
67
|
+
Routes support static paths and dynamic `:param` segments:
|
|
68
|
+
|
|
69
|
+
```js
|
|
70
|
+
{ path: '/', view: 'home' } // exact match
|
|
71
|
+
{ path: '/users', view: 'users' } // exact match
|
|
72
|
+
{ path: '/users/:id', view: 'user' } // dynamic segment
|
|
73
|
+
{ path: '/posts/:id/edit', view: 'edit-post' } // mixed
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Params are extracted and available at `ui.route.params`:
|
|
77
|
+
|
|
78
|
+
```js
|
|
79
|
+
// URL: /users/42
|
|
80
|
+
store.get('ui.route.params'); // { id: '42' }
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## View Components
|
|
84
|
+
|
|
85
|
+
A view component is any object with a `boot` method:
|
|
86
|
+
|
|
87
|
+
```js
|
|
88
|
+
const UserView = {
|
|
89
|
+
async boot({ store, el, signal, params }) {
|
|
90
|
+
el.innerHTML = `<h1>User ${params.id}</h1>`;
|
|
91
|
+
|
|
92
|
+
// Use signal for cleanup-aware async work
|
|
93
|
+
const res = await fetch(`/api/users/${params.id}`, { signal });
|
|
94
|
+
const user = await res.json();
|
|
95
|
+
el.innerHTML = `<h1>${user.name}</h1>`;
|
|
96
|
+
|
|
97
|
+
// Return an unboot function for cleanup
|
|
98
|
+
return () => {
|
|
99
|
+
console.log('UserView unmounted');
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
The `boot` function receives:
|
|
106
|
+
|
|
107
|
+
| Param | Description |
|
|
108
|
+
|---|---|
|
|
109
|
+
| `store` | The EventState store instance |
|
|
110
|
+
| `el` | The root DOM element (from `rootSelector`) |
|
|
111
|
+
| `signal` | An `AbortSignal` — aborted if the user navigates away before boot finishes |
|
|
112
|
+
| `params` | Extracted route params (e.g. `{ id: '42' }`) |
|
|
113
|
+
|
|
114
|
+
## API
|
|
115
|
+
|
|
116
|
+
### `createRouter(config)`
|
|
117
|
+
|
|
118
|
+
Returns a router instance.
|
|
119
|
+
|
|
120
|
+
**Config options:**
|
|
121
|
+
|
|
122
|
+
| Option | Type | Default | Description |
|
|
123
|
+
|---|---|---|---|
|
|
124
|
+
| `routes` | `Array` | `[]` | Route definitions |
|
|
125
|
+
| `store` | `Object` | — | EventState store |
|
|
126
|
+
| `rootSelector` | `string` | `'[data-route-root]'` | CSS selector for the mount point |
|
|
127
|
+
| `fallback` | `Object` | `null` | Fallback route for unmatched paths |
|
|
128
|
+
| `debug` | `boolean` | `false` | Log navigation to console |
|
|
129
|
+
| `linkSelector` | `string` | `'a[data-link]'` | Selector for intercepted link clicks |
|
|
130
|
+
|
|
131
|
+
### Router Instance
|
|
132
|
+
|
|
133
|
+
#### `router.start()`
|
|
134
|
+
|
|
135
|
+
Starts listening for link clicks and popstate events. Immediately navigates to the current URL.
|
|
136
|
+
|
|
137
|
+
#### `router.stop()`
|
|
138
|
+
|
|
139
|
+
Removes event listeners and calls the current view's unboot function.
|
|
140
|
+
|
|
141
|
+
#### `router.navigate(pathname, opts?)`
|
|
142
|
+
|
|
143
|
+
Programmatic navigation.
|
|
144
|
+
|
|
145
|
+
```js
|
|
146
|
+
router.navigate('/users/42');
|
|
147
|
+
router.navigate('/search', { search: '?q=hello' });
|
|
148
|
+
router.navigate('/users', { replace: true });
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Options: `{ replace, search, restoreScroll }`
|
|
152
|
+
|
|
153
|
+
#### `router.navigateQuery(patch, opts?)`
|
|
154
|
+
|
|
155
|
+
Patch query parameters without changing the path.
|
|
156
|
+
|
|
157
|
+
```js
|
|
158
|
+
router.navigateQuery({ tab: 'posts' }); // add/update
|
|
159
|
+
router.navigateQuery({ tab: null }); // remove
|
|
160
|
+
router.navigateQuery({ page: '2', sort: 'name' }); // multiple
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
#### `router.getCurrent()`
|
|
164
|
+
|
|
165
|
+
Returns `{ view, path, search }` for the current route.
|
|
166
|
+
|
|
167
|
+
## Link Interception
|
|
168
|
+
|
|
169
|
+
Any `<a>` matching `linkSelector` (default: `a[data-link]`) is intercepted for client-side navigation:
|
|
170
|
+
|
|
171
|
+
```html
|
|
172
|
+
<nav>
|
|
173
|
+
<a href="/" data-link>Home</a>
|
|
174
|
+
<a href="/users" data-link>Users</a>
|
|
175
|
+
<a href="/users/42" data-link>User 42</a>
|
|
176
|
+
</nav>
|
|
177
|
+
|
|
178
|
+
<div data-route-root></div>
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Standard browser behavior is preserved for:
|
|
182
|
+
- External links (different origin)
|
|
183
|
+
- Modified clicks (Ctrl, Cmd, Shift, Alt, right-click)
|
|
184
|
+
- Links without `data-link`
|
|
185
|
+
|
|
186
|
+
## Active Nav (Subscribe, Don't Bake In)
|
|
187
|
+
|
|
188
|
+
The router does **not** manage active nav styles. Instead, subscribe to the route path and manage your own UI:
|
|
189
|
+
|
|
190
|
+
```js
|
|
191
|
+
store.subscribe('ui.route.path', (path) => {
|
|
192
|
+
document.querySelectorAll('nav a[data-link]').forEach(a => {
|
|
193
|
+
const href = new URL(a.getAttribute('href'), location.href).pathname;
|
|
194
|
+
a.classList.toggle('active', href === path);
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
This keeps the router focused on state. Your nav, your rules.
|
|
200
|
+
|
|
201
|
+
## Base Path Support
|
|
202
|
+
|
|
203
|
+
If your app is served from a subdirectory, add a `<base>` tag:
|
|
204
|
+
|
|
205
|
+
```html
|
|
206
|
+
<base href="/my-app/">
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
The router automatically detects it and adjusts all path operations.
|
|
210
|
+
|
|
211
|
+
## Scroll Restoration
|
|
212
|
+
|
|
213
|
+
The router saves scroll positions per route and restores them on back/forward navigation. Forward navigation scrolls to top.
|
|
214
|
+
|
|
215
|
+
## Accessibility
|
|
216
|
+
|
|
217
|
+
On every navigation, the router:
|
|
218
|
+
1. Sets `tabindex="-1"` on the root element (if not already set)
|
|
219
|
+
2. Focuses the root element (with `preventScroll`)
|
|
220
|
+
|
|
221
|
+
This ensures screen readers announce the new content.
|
|
222
|
+
|
|
223
|
+
## CSS Hooks
|
|
224
|
+
|
|
225
|
+
The router sets attributes on `<html>` for CSS-driven transitions:
|
|
226
|
+
|
|
227
|
+
```css
|
|
228
|
+
/* Style based on current view */
|
|
229
|
+
[data-view="home"] .hero { display: block; }
|
|
230
|
+
[data-view="user"] .sidebar { display: flex; }
|
|
231
|
+
|
|
232
|
+
/* Transition states */
|
|
233
|
+
[data-transitioning="on"] [data-route-root] {
|
|
234
|
+
opacity: 0.5;
|
|
235
|
+
pointer-events: none;
|
|
236
|
+
}
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
## Philosophy
|
|
240
|
+
|
|
241
|
+
Routing is not special. It's a `set` call to a path in a JSON tree. The router writes `ui.route.*`, and anything that cares about routing subscribes to `ui.route.*`. The router doesn't know about your nav, your breadcrumbs, your analytics, or your loading spinners. They all subscribe independently. That's UIState: EventState + Routing.
|
|
242
|
+
|
|
243
|
+
## License
|
|
244
|
+
|
|
245
|
+
MIT
|
package/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { createRouter } from './router.js';
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@uistate/router",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "SPA router for EventState stores — routing is just state",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"module": "index.js",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./index.js"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"index.js",
|
|
13
|
+
"router.js",
|
|
14
|
+
"README.md",
|
|
15
|
+
"LICENSE"
|
|
16
|
+
],
|
|
17
|
+
"keywords": [
|
|
18
|
+
"router",
|
|
19
|
+
"spa",
|
|
20
|
+
"eventstate",
|
|
21
|
+
"uistate",
|
|
22
|
+
"state-management",
|
|
23
|
+
"path-based",
|
|
24
|
+
"vanilla-js",
|
|
25
|
+
"framework-agnostic"
|
|
26
|
+
],
|
|
27
|
+
"author": "",
|
|
28
|
+
"license": "MIT",
|
|
29
|
+
"repository": {
|
|
30
|
+
"type": "git",
|
|
31
|
+
"url": "https://github.com/ImsirovicAjdin/uistate-router"
|
|
32
|
+
},
|
|
33
|
+
"homepage": "https://uistate.com",
|
|
34
|
+
"peerDependencies": {
|
|
35
|
+
"@uistate/core": ">=5.6.0"
|
|
36
|
+
},
|
|
37
|
+
"peerDependenciesMeta": {
|
|
38
|
+
"@uistate/core": {
|
|
39
|
+
"optional": true
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
package/router.js
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
// @uistate/router — SPA router factory for EventState stores
|
|
2
|
+
// Routing is just state: navigate() writes to store paths, components subscribe.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Compile a route pattern like '/users/:id/posts/:postId' into a matcher.
|
|
6
|
+
* Returns { regex, paramNames } for extraction.
|
|
7
|
+
*/
|
|
8
|
+
function compilePattern(pattern) {
|
|
9
|
+
const paramNames = [];
|
|
10
|
+
const parts = pattern.split(/:([a-zA-Z_][a-zA-Z0-9_]*)/);
|
|
11
|
+
const regexStr = parts
|
|
12
|
+
.map((part, i) => {
|
|
13
|
+
if (i % 2 === 1) {
|
|
14
|
+
paramNames.push(part);
|
|
15
|
+
return '([^/]+)';
|
|
16
|
+
}
|
|
17
|
+
return part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
18
|
+
})
|
|
19
|
+
.join('');
|
|
20
|
+
return { regex: new RegExp('^' + regexStr + '$'), paramNames };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Create a SPA router bound to an EventState store.
|
|
25
|
+
*
|
|
26
|
+
* @param {Object} config
|
|
27
|
+
* @param {Array} config.routes - [{ path: '/users/:id', view: 'user', component: UserView }]
|
|
28
|
+
* @param {Object} [config.store] - EventState store instance
|
|
29
|
+
* @param {string} [config.rootSelector='[data-route-root]'] - Root element for view mounting
|
|
30
|
+
* @param {Object} [config.fallback] - Fallback route when nothing matches
|
|
31
|
+
* @param {boolean} [config.debug=false]
|
|
32
|
+
* @param {string} [config.linkSelector='a[data-link]'] - Selector for intercepted links
|
|
33
|
+
* @param {string} [config.navSelector='nav a[data-link]'] - Selector for nav links to toggle .active class
|
|
34
|
+
*
|
|
35
|
+
* Store-driven navigation (requires store):
|
|
36
|
+
* Any code with store access can navigate without importing the router:
|
|
37
|
+
* - store.set('ui.route.go', '/about')
|
|
38
|
+
* - store.set('ui.route.go', { path: '/users/1', search: '?tab=posts' })
|
|
39
|
+
* - store.set('ui.route.go', { query: { tab: 'posts' } }) // patch query only
|
|
40
|
+
*/
|
|
41
|
+
export function createRouter(config) {
|
|
42
|
+
const {
|
|
43
|
+
routes = [],
|
|
44
|
+
store,
|
|
45
|
+
rootSelector = '[data-route-root]',
|
|
46
|
+
fallback = null,
|
|
47
|
+
debug = false,
|
|
48
|
+
linkSelector = 'a[data-link]',
|
|
49
|
+
navSelector = 'nav a[data-link]',
|
|
50
|
+
} = config;
|
|
51
|
+
|
|
52
|
+
// Pre-compile route patterns
|
|
53
|
+
const compiled = routes.map(route => ({
|
|
54
|
+
...route,
|
|
55
|
+
...compilePattern(route.path),
|
|
56
|
+
}));
|
|
57
|
+
|
|
58
|
+
const compiledFallback = fallback
|
|
59
|
+
? { ...fallback, ...compilePattern(fallback.path || '/*'), params: {} }
|
|
60
|
+
: null;
|
|
61
|
+
|
|
62
|
+
// Detect base path from <base href> if present
|
|
63
|
+
const BASE_PATH = (() => {
|
|
64
|
+
const b = document.querySelector('base[href]');
|
|
65
|
+
if (!b) return '';
|
|
66
|
+
try {
|
|
67
|
+
const u = new URL(b.getAttribute('href'), location.href);
|
|
68
|
+
let p = u.pathname;
|
|
69
|
+
if (p.length > 1 && p.endsWith('/')) p = p.slice(0, -1);
|
|
70
|
+
return p;
|
|
71
|
+
} catch { return ''; }
|
|
72
|
+
})();
|
|
73
|
+
|
|
74
|
+
function stripBase(pathname) {
|
|
75
|
+
if (BASE_PATH && pathname.startsWith(BASE_PATH)) {
|
|
76
|
+
const rest = pathname.slice(BASE_PATH.length) || '/';
|
|
77
|
+
return rest.startsWith('/') ? rest : ('/' + rest);
|
|
78
|
+
}
|
|
79
|
+
return pathname;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function withBase(pathname) {
|
|
83
|
+
if (!BASE_PATH) return pathname;
|
|
84
|
+
if (pathname === '/') return BASE_PATH || '/';
|
|
85
|
+
return BASE_PATH + (pathname.startsWith('/') ? '' : '/') + pathname;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function normalizePath(p) {
|
|
89
|
+
if (!p) return '/';
|
|
90
|
+
if (p[0] !== '/') p = '/' + p;
|
|
91
|
+
if (p === '/index.html') return '/';
|
|
92
|
+
if (p.length > 1 && p.endsWith('/')) p = p.slice(0, -1);
|
|
93
|
+
return p;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function resolve(pathname) {
|
|
97
|
+
const p = normalizePath(pathname);
|
|
98
|
+
for (const route of compiled) {
|
|
99
|
+
const match = p.match(route.regex);
|
|
100
|
+
if (match) {
|
|
101
|
+
const params = {};
|
|
102
|
+
route.paramNames.forEach((name, i) => {
|
|
103
|
+
params[name] = decodeURIComponent(match[i + 1]);
|
|
104
|
+
});
|
|
105
|
+
return { path: route.path, view: route.view, component: route.component, params };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (compiledFallback) return { ...compiledFallback, params: {} };
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function getRoot() {
|
|
113
|
+
const el = document.querySelector(rootSelector);
|
|
114
|
+
if (!el) throw new Error('[router] Route root not found: ' + rootSelector);
|
|
115
|
+
return el;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function log(...args) {
|
|
119
|
+
if (debug) console.debug('[router]', ...args);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function setActiveNav(pathname) {
|
|
123
|
+
document.querySelectorAll(navSelector).forEach(a => {
|
|
124
|
+
const url = new URL(a.getAttribute('href'), location.href);
|
|
125
|
+
const linkPath = normalizePath(stripBase(url.pathname));
|
|
126
|
+
const here = normalizePath(pathname);
|
|
127
|
+
const isExact = linkPath === here;
|
|
128
|
+
const isParent = !isExact && linkPath !== '/' && here.startsWith(linkPath);
|
|
129
|
+
const active = isExact || isParent;
|
|
130
|
+
a.classList.toggle('active', active);
|
|
131
|
+
if (isExact) a.setAttribute('aria-current', 'page');
|
|
132
|
+
else a.removeAttribute('aria-current');
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Internal state
|
|
137
|
+
let current = { viewKey: null, unboot: null, path: null, search: '' };
|
|
138
|
+
let navController = null;
|
|
139
|
+
const scrollPositions = new Map();
|
|
140
|
+
history.scrollRestoration = 'manual';
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Navigate to a pathname.
|
|
144
|
+
* @param {string} pathname
|
|
145
|
+
* @param {Object} [opts]
|
|
146
|
+
* @param {boolean} [opts.replace=false]
|
|
147
|
+
* @param {string} [opts.search='']
|
|
148
|
+
* @param {boolean} [opts.restoreScroll=false]
|
|
149
|
+
*/
|
|
150
|
+
async function navigate(pathname, { replace = false, search = '', restoreScroll = false } = {}) {
|
|
151
|
+
const root = getRoot();
|
|
152
|
+
const appPath = normalizePath(stripBase(pathname));
|
|
153
|
+
const resolved = resolve(appPath);
|
|
154
|
+
|
|
155
|
+
if (!resolved) {
|
|
156
|
+
log('no route found for:', appPath);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const viewKey = resolved.view;
|
|
161
|
+
const component = resolved.component;
|
|
162
|
+
const searchStr = search && search.startsWith('?') ? search : (search ? ('?' + search) : '');
|
|
163
|
+
|
|
164
|
+
log('navigate', { from: current.path, to: appPath, view: viewKey, params: resolved.params });
|
|
165
|
+
|
|
166
|
+
// Same-route no-op guard
|
|
167
|
+
if (current.path === appPath && current.search === searchStr) {
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Abort in-flight boot
|
|
172
|
+
if (navController) navController.abort();
|
|
173
|
+
navController = new AbortController();
|
|
174
|
+
const { signal } = navController;
|
|
175
|
+
|
|
176
|
+
// Transition start
|
|
177
|
+
const html = document.documentElement;
|
|
178
|
+
html.setAttribute('data-transitioning', 'on');
|
|
179
|
+
if (store) {
|
|
180
|
+
try { store.set('ui.route.transitioning', true); } catch {}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Save scroll position for current route
|
|
184
|
+
if (current.path) {
|
|
185
|
+
scrollPositions.set(current.path, { x: scrollX, y: scrollY });
|
|
186
|
+
if (scrollPositions.size > 50) scrollPositions.delete(scrollPositions.keys().next().value);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Unboot previous view
|
|
190
|
+
if (typeof current.unboot === 'function') {
|
|
191
|
+
try { await current.unboot(); } catch {}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Clear root
|
|
195
|
+
root.replaceChildren();
|
|
196
|
+
|
|
197
|
+
// Boot new view
|
|
198
|
+
let unboot = null;
|
|
199
|
+
if (component && typeof component.boot === 'function') {
|
|
200
|
+
unboot = await component.boot({ store, el: root, signal, params: resolved.params });
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Guard: if navigation was superseded during boot, bail out
|
|
204
|
+
if (signal.aborted) return;
|
|
205
|
+
|
|
206
|
+
const prevViewKey = current.viewKey;
|
|
207
|
+
current = { viewKey, unboot, path: appPath, search: searchStr };
|
|
208
|
+
|
|
209
|
+
// Parse query params
|
|
210
|
+
const fullUrl = new URL(location.origin + withBase(appPath) + searchStr);
|
|
211
|
+
const query = {};
|
|
212
|
+
fullUrl.searchParams.forEach((v, k) => { query[k] = v; });
|
|
213
|
+
|
|
214
|
+
// Update store with route state + end transition atomically
|
|
215
|
+
if (store) {
|
|
216
|
+
try {
|
|
217
|
+
store.setMany({
|
|
218
|
+
'ui.route.view': viewKey,
|
|
219
|
+
'ui.route.path': appPath,
|
|
220
|
+
'ui.route.params': resolved.params || {},
|
|
221
|
+
'ui.route.query': query,
|
|
222
|
+
'ui.route.transitioning': false,
|
|
223
|
+
});
|
|
224
|
+
} catch {}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Update browser history
|
|
228
|
+
const useReplace = replace;
|
|
229
|
+
if (useReplace) history.replaceState({}, '', withBase(appPath) + searchStr);
|
|
230
|
+
else history.pushState({}, '', withBase(appPath) + searchStr);
|
|
231
|
+
|
|
232
|
+
// Set view attribute on <html> for CSS hooks
|
|
233
|
+
html.setAttribute('data-view', viewKey);
|
|
234
|
+
html.setAttribute('data-transitioning', 'off');
|
|
235
|
+
|
|
236
|
+
// Update nav active state
|
|
237
|
+
setActiveNav(appPath);
|
|
238
|
+
|
|
239
|
+
// Focus management (accessibility)
|
|
240
|
+
if (!root.hasAttribute('tabindex')) root.setAttribute('tabindex', '-1');
|
|
241
|
+
try { root.focus({ preventScroll: true }); } catch {}
|
|
242
|
+
|
|
243
|
+
// Scroll
|
|
244
|
+
if (restoreScroll) {
|
|
245
|
+
const pos = scrollPositions.get(appPath);
|
|
246
|
+
if (pos) scrollTo(pos.x, pos.y);
|
|
247
|
+
} else {
|
|
248
|
+
scrollTo(0, 0);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
log('routed', { view: viewKey, path: appPath, params: resolved.params, query });
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Patch query parameters without changing the path.
|
|
256
|
+
* Pass null/undefined/'' as a value to remove a key.
|
|
257
|
+
*/
|
|
258
|
+
function navigateQuery(patch = {}, { replace = true } = {}) {
|
|
259
|
+
const params = new URLSearchParams(current.search?.replace(/^\?/, '') || '');
|
|
260
|
+
for (const [k, v] of Object.entries(patch)) {
|
|
261
|
+
if (v === null || v === undefined || v === '') params.delete(k);
|
|
262
|
+
else params.set(k, String(v));
|
|
263
|
+
}
|
|
264
|
+
const searchStr = params.toString();
|
|
265
|
+
const prefixed = searchStr ? ('?' + searchStr) : '';
|
|
266
|
+
const path = current.path || normalizePath(stripBase(location.pathname));
|
|
267
|
+
return navigate(path, { search: prefixed, replace });
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Navigate to a new path, keeping the current search string.
|
|
272
|
+
* @param {string} path
|
|
273
|
+
* @param {Object} [opts]
|
|
274
|
+
* @param {boolean} [opts.replace=true]
|
|
275
|
+
*/
|
|
276
|
+
function navigatePath(path, { replace = true } = {}) {
|
|
277
|
+
const appPath = normalizePath(stripBase(path));
|
|
278
|
+
const searchStr = current.search || '';
|
|
279
|
+
return navigate(appPath, { search: searchStr, replace });
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Event handlers
|
|
283
|
+
function onClick(e) {
|
|
284
|
+
const a = e.target.closest(linkSelector);
|
|
285
|
+
if (!a) return;
|
|
286
|
+
if (e.defaultPrevented) return;
|
|
287
|
+
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) return;
|
|
288
|
+
const href = a.getAttribute('href');
|
|
289
|
+
if (!href) return;
|
|
290
|
+
const url = new URL(href, location.href);
|
|
291
|
+
if (url.origin !== location.origin) return;
|
|
292
|
+
e.preventDefault();
|
|
293
|
+
log('click', { href, text: a.textContent.trim() });
|
|
294
|
+
navigate(url.pathname, { search: url.search }).catch(() => {});
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function onPop() {
|
|
298
|
+
navigate(location.pathname, {
|
|
299
|
+
replace: true,
|
|
300
|
+
search: location.search,
|
|
301
|
+
restoreScroll: true,
|
|
302
|
+
}).catch(() => {});
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Store-driven navigation: write ui.route.go to navigate from anywhere
|
|
306
|
+
let unsubGo = null;
|
|
307
|
+
let processingGo = false;
|
|
308
|
+
if (store) {
|
|
309
|
+
unsubGo = store.subscribe('ui.route.go', (value) => {
|
|
310
|
+
if (processingGo || !value) return;
|
|
311
|
+
processingGo = true;
|
|
312
|
+
try { store.set('ui.route.go', null); } catch {}
|
|
313
|
+
processingGo = false;
|
|
314
|
+
|
|
315
|
+
if (typeof value === 'string') {
|
|
316
|
+
navigate(value).catch(() => {});
|
|
317
|
+
} else if (typeof value === 'object') {
|
|
318
|
+
if (!value.path && value.query) {
|
|
319
|
+
navigateQuery(value.query, { replace: value.replace ?? true }).catch(() => {});
|
|
320
|
+
} else {
|
|
321
|
+
navigate(value.path || '/', {
|
|
322
|
+
search: value.search || '',
|
|
323
|
+
replace: value.replace || false,
|
|
324
|
+
}).catch(() => {});
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Public API
|
|
331
|
+
return {
|
|
332
|
+
navigate,
|
|
333
|
+
navigateQuery,
|
|
334
|
+
navigatePath,
|
|
335
|
+
|
|
336
|
+
start() {
|
|
337
|
+
window.addEventListener('click', onClick);
|
|
338
|
+
window.addEventListener('popstate', onPop);
|
|
339
|
+
navigate(location.pathname, {
|
|
340
|
+
replace: true,
|
|
341
|
+
search: location.search,
|
|
342
|
+
restoreScroll: true,
|
|
343
|
+
});
|
|
344
|
+
return this;
|
|
345
|
+
},
|
|
346
|
+
|
|
347
|
+
stop() {
|
|
348
|
+
window.removeEventListener('click', onClick);
|
|
349
|
+
window.removeEventListener('popstate', onPop);
|
|
350
|
+
if (unsubGo) { unsubGo(); unsubGo = null; }
|
|
351
|
+
if (typeof current.unboot === 'function') {
|
|
352
|
+
try { Promise.resolve(current.unboot()).catch(() => {}); } catch {}
|
|
353
|
+
}
|
|
354
|
+
return this;
|
|
355
|
+
},
|
|
356
|
+
|
|
357
|
+
getCurrent() {
|
|
358
|
+
return {
|
|
359
|
+
view: current.viewKey,
|
|
360
|
+
path: current.path,
|
|
361
|
+
search: current.search,
|
|
362
|
+
};
|
|
363
|
+
},
|
|
364
|
+
};
|
|
365
|
+
}
|