@dharmax/state-router 3.2.0 → 3.2.1
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/ReadMe.md +169 -38
- package/dist/router.d.ts +19 -4
- package/dist/router.js +183 -38
- package/dist/state-manager.d.ts +12 -4
- package/dist/state-manager.js +44 -10
- package/dist-cjs/index.cjs +18 -0
- package/dist-cjs/router.cjs +265 -0
- package/dist-cjs/router.d.ts +41 -0
- package/dist-cjs/state-manager.cjs +150 -0
- package/dist-cjs/state-manager.d.ts +56 -0
- package/package.json +29 -7
- package/src/router.ts +0 -141
- package/src/state-manager.ts +0 -141
- package/test/index.html +0 -21
- package/tsconfig.json +0 -23
- /package/{src/index.ts → dist-cjs/index.d.ts} +0 -0
package/ReadMe.md
CHANGED
|
@@ -1,57 +1,188 @@
|
|
|
1
1
|
|
|
2
|
-
|
|
3
|
-
This package contains a functional router and a web-application state manager.
|
|
2
|
+
## Overview
|
|
4
3
|
|
|
5
|
-
|
|
6
|
-
per specific pattern of url, it simply triggers a handler. It supports hash notation
|
|
7
|
-
as well as normal url patterns. It also supports contexts (url parameters).
|
|
4
|
+
This package contains a tiny, functional router and a web‑application state manager.
|
|
8
5
|
|
|
9
|
-
|
|
10
|
-
to work with, you get a very simple semantic application state management:
|
|
11
|
-
each state can be given a logical name, route and an associated page/component.
|
|
6
|
+
The router captures URL changes and triggers a handler for the first matching route pattern. It supports both hash (`#/path`) and history (`/path`) modes, and passes route parameters and query data to your handler.
|
|
12
7
|
|
|
13
|
-
The
|
|
14
|
-
and you can access the router's staticFilters member to replace or add other rules
|
|
15
|
-
for static serving support.
|
|
8
|
+
The state manager provides a minimal semantic state layer on top of the router: define named states, their route, and optional mode(s); listen for changes; and gate transitions with async guards.
|
|
16
9
|
|
|
17
|
-
|
|
10
|
+
- Static files: the router ignores common static file extensions (e.g. `.css`, `.js`, `.png`, `.svg`, `.webp`, `.json`, `.md`, `.txt`, `.ejs`, `.jsm`). You can customize `router.staticFilters` to adjust.
|
|
11
|
+
- Modes: use `router.listen('hash' | 'history')`. For static file serving (file:// or a simple static server), prefer `hash`.
|
|
18
12
|
|
|
13
|
+
## Installation
|
|
19
14
|
|
|
20
|
-
|
|
15
|
+
Install as usual and build the TypeScript sources:
|
|
21
16
|
|
|
22
|
-
|
|
17
|
+
```
|
|
18
|
+
npm install
|
|
19
|
+
npm run build
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Quick Start
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
import { router, StateManager } from '@dharmax/state-router'
|
|
26
|
+
|
|
27
|
+
// Router: match params and use query context
|
|
28
|
+
router
|
|
29
|
+
.add(/^user\/(\d+)$/, function (id: string) {
|
|
30
|
+
// `this` holds query params from the current URL
|
|
31
|
+
// @ts-ignore
|
|
32
|
+
console.log('user', id, 'q=', this.queryParams?.q)
|
|
33
|
+
})
|
|
34
|
+
.listen('hash')
|
|
35
|
+
|
|
36
|
+
// State Manager: define states and react
|
|
37
|
+
const sm = new StateManager('hash')
|
|
38
|
+
sm.addState('home', 'home', /^home$/)
|
|
39
|
+
sm.addState('post', 'post', /^post\/(\w+)$/)
|
|
23
40
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
stateManager.addState('discussion', 'discussion-page', 'discussion/%')
|
|
32
|
-
stateManager.addState('article', 'article-page', 'article/%','article-mode')
|
|
33
|
-
stateManager.addState('blog', 'article-page', 'blog/%','blog-mode')
|
|
41
|
+
sm.onChange((event, state) => {
|
|
42
|
+
console.log('state changed to', state.name, 'context=', sm.context)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
// Navigate
|
|
46
|
+
router.navigate('home')
|
|
47
|
+
router.navigate('post/hello')
|
|
34
48
|
```
|
|
35
49
|
|
|
36
|
-
##
|
|
50
|
+
## Router API
|
|
51
|
+
|
|
52
|
+
- `router.add(pattern: RegExp | RouteHandler, handler?: RouteHandler)`
|
|
53
|
+
- If `pattern` is a RegExp, captured groups are passed as handler arguments.
|
|
54
|
+
- If `pattern` is omitted (i.e., you pass only a function), it becomes a catch‑all route.
|
|
55
|
+
- The handler’s `this` contains `queryParams` built from `window.location.search`.
|
|
56
|
+
- String patterns with named params are supported: `'/users/:id'` produces `this.params = { id: '...' }` and still passes the captured values as handler args.
|
|
57
|
+
|
|
58
|
+
- `router.listen(mode?: 'hash' | 'history')`
|
|
59
|
+
- In `history` mode, internal `<a href="/...">` clicks and Enter on focused links are intercepted.
|
|
60
|
+
- Interception is hardened: uses `closest('a')`; ignores modified clicks (meta/ctrl/shift), non‑left clicks, `target=_blank`, `download`, `rel=noreferrer`, and external origins or different hostnames; and skips static‑looking URLs (by extension).
|
|
61
|
+
|
|
62
|
+
- `router.navigate(path: string, opts?: { replace?: boolean })`
|
|
63
|
+
- Navigates according to the active mode and triggers routing.
|
|
64
|
+
- If `opts.replace` is true (history mode), uses `history.replaceState` instead of `pushState`.
|
|
65
|
+
|
|
66
|
+
- `router.replace(path: string)`
|
|
67
|
+
- Shorthand for `router.navigate(path, { replace: true })`.
|
|
68
|
+
|
|
69
|
+
- `router.resetRoot(root: string)`
|
|
70
|
+
- Set a base root for history URL calculation.
|
|
71
|
+
|
|
72
|
+
- `router.unlisten()`
|
|
73
|
+
- Removes all listeners previously attached by `listen()` (click, keydown, popstate/hashchange). Useful for cleanup and tests.
|
|
74
|
+
|
|
75
|
+
- `router.onNotFound(handler)`
|
|
76
|
+
- Registers a fallback called when no routes match. Returns `true` from `handleChange()` after invoking the hook.
|
|
77
|
+
|
|
78
|
+
- `router.getQueryParams(search?: string)`
|
|
79
|
+
- Returns a parsed query map from the current URL (or from a provided search string). Useful if you’d rather not use handler `this`.
|
|
80
|
+
|
|
81
|
+
- `router.setDecodeParams(boolean)`
|
|
82
|
+
- Optionally `decodeURIComponent` route parameters before passing them to handlers and `this.params`.
|
|
83
|
+
|
|
84
|
+
Notes: the router lazily accesses `window`/`document` to be SSR‑safe; outside a browser environment, listeners are not attached and navigation no‑ops.
|
|
85
|
+
|
|
86
|
+
- `createRouter()`
|
|
87
|
+
- Factory that returns a fresh Router instance. Useful for testing or isolating multiple routers.
|
|
88
|
+
|
|
89
|
+
## State Manager API
|
|
90
|
+
|
|
91
|
+
- `new StateManager(mode?: 'hash' | 'history', autostart = true, routerInstance = router)`
|
|
92
|
+
- When `autostart` is true, calls `router.listen(mode)` automatically.
|
|
93
|
+
- You can pass a custom router instance (e.g., from `createRouter()`) for isolation.
|
|
94
|
+
- Call `sm.stop()` to unlisten the router.
|
|
95
|
+
|
|
96
|
+
- `addState(name, pageName?, route?: RegExp | string, mode?: string | string[])`
|
|
97
|
+
- If `route` is a string and contains `%`, each `%` is expanded to a non‑mandatory capture `?(.*)` for “the rest of the path”. For example, `'docs%'` becomes `^docs?(.*)$` and the first capture is provided as the state context (e.g., `'/guide'`).
|
|
98
|
+
- If `route` is a RegExp, the first capturing group is passed as the state context.
|
|
37
99
|
|
|
38
|
-
|
|
100
|
+
- `setState(name, context?)`
|
|
101
|
+
- Programmatically set the state and optional context (e.g., a sub‑state or id).
|
|
39
102
|
|
|
40
|
-
|
|
103
|
+
- `getState()` / `previous` / `context`
|
|
104
|
+
- Access current, previous state, and the last context value.
|
|
105
|
+
- Context can be a string, an array (for multi‑capture regex), or an object (for named params).
|
|
41
106
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
107
|
+
- `onChange(handler)`
|
|
108
|
+
- Subscribes to `state:changed` events via `@dharmax/pubsub`.
|
|
109
|
+
|
|
110
|
+
- `onBeforeChange(handler)` / `onAfterChange(handler)`
|
|
111
|
+
- Optional hooks around transitions. `onBeforeChange` can veto by returning `false` (sync or async). `onAfterChange` runs after a successful transition.
|
|
112
|
+
|
|
113
|
+
- `onNotFound(handler)`
|
|
114
|
+
- Subscribe to router‐level notFound events via the state manager for convenience.
|
|
115
|
+
|
|
116
|
+
- `registerChangeAuthority(authority: (target) => Promise<boolean>)`
|
|
117
|
+
- All registered authorities must return `true` to allow a transition.
|
|
118
|
+
|
|
119
|
+
- `restoreState(defaultState)`
|
|
120
|
+
- Attempts to restore from current URL; otherwise navigates to the default state (hash mode).
|
|
121
|
+
|
|
122
|
+
- `createStateManager(mode?: 'hash' | 'history', autostart = true, routerInstance = router)`
|
|
123
|
+
- Factory returning a new StateManager; pass a custom router if desired.
|
|
124
|
+
|
|
125
|
+
## Data, Context, and Parameter Passing
|
|
126
|
+
|
|
127
|
+
- Route parameters: each capturing group in your route RegExp is passed to the route handler as an argument in order. For `^user\/(\d+)$`, the handler receives the user id string.
|
|
128
|
+
- Query params: inside a route handler, `this.queryParams` exposes an object of the URL’s query parameters (e.g., `{ q: 'hello' }`).
|
|
129
|
+
- State context: when a route defined via `addState` matches, the first capture group is forwarded to the StateManager as the state “context”. Access it via `stateManager.context` after the transition.
|
|
130
|
+
|
|
131
|
+
## Examples
|
|
132
|
+
|
|
133
|
+
```ts
|
|
134
|
+
// 1) Params + query
|
|
135
|
+
router.add(/^user\/(\d+)$/, function (id) {
|
|
136
|
+
// @ts-ignore
|
|
137
|
+
const { q } = this.queryParams
|
|
138
|
+
console.log('id=', id, 'q=', q)
|
|
47
139
|
})
|
|
48
140
|
|
|
141
|
+
// 2) State context from route
|
|
142
|
+
sm.addState('docs', 'docs', 'docs%') // captures the suffix as context, e.g. '/guide'
|
|
49
143
|
|
|
144
|
+
// 3) Async guard
|
|
145
|
+
sm.registerChangeAuthority(async (target) => {
|
|
146
|
+
return target.name !== 'admin-only'
|
|
147
|
+
})
|
|
50
148
|
```
|
|
51
149
|
|
|
52
|
-
##
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
150
|
+
## Development & Testing
|
|
151
|
+
|
|
152
|
+
- Build: `npm run build` → compiles TypeScript into `dist/`.
|
|
153
|
+
- Manual demo: serve `test/` (e.g., `npx http-server test`) after build. Use `hash` mode for static servers.
|
|
154
|
+
- Automated tests: Vitest + jsdom
|
|
155
|
+
- Run once with coverage: `npm test`
|
|
156
|
+
- Watch mode: `npm run test:watch`
|
|
157
|
+
- Notes:
|
|
158
|
+
- Tests use dynamic imports with `vi.resetModules()` to isolate the singleton router/state manager between cases.
|
|
159
|
+
- Some tests mock `URLSearchParams` to simulate query strings in jsdom without full navigation.
|
|
160
|
+
- Tests use history mode in jsdom via `history.pushState` and `popstate` events; avoid direct `window.location.search = '...'` (jsdom limitation).
|
|
161
|
+
|
|
162
|
+
## History Mode Server Config
|
|
163
|
+
|
|
164
|
+
When using `history` mode, your server must serve your SPA entry (e.g., `index.html`) for application routes to avoid 404s on refresh or deep links. Static assets should still be served normally.
|
|
165
|
+
|
|
166
|
+
Examples:
|
|
167
|
+
|
|
168
|
+
- Node/Express
|
|
169
|
+
- Serve static first, then a catch‑all returning `index.html`.
|
|
170
|
+
- `app.use(express.static('public'))`
|
|
171
|
+
- `app.get('*', (req, res) => res.sendFile(path.join(__dirname, 'public/index.html')))`
|
|
172
|
+
|
|
173
|
+
- Nginx
|
|
174
|
+
- In your `location /` block: `try_files $uri /index.html;`
|
|
175
|
+
|
|
176
|
+
- Apache
|
|
177
|
+
- Use `FallbackResource /index.html` or an `.htaccess` rewrite.
|
|
178
|
+
|
|
179
|
+
Tip: Keep `router.staticFilters` tuned so links to real files (e.g., `/assets/app.css`) are not intercepted.
|
|
180
|
+
|
|
181
|
+
## Analytics Hooks
|
|
182
|
+
|
|
183
|
+
If present, the following globals will be invoked on successful state changes:
|
|
184
|
+
|
|
185
|
+
- `window.pageChangeHandler('send', 'pageview', '/<state>/<context>')`
|
|
186
|
+
- `window.ga('send', 'pageview', '/<state>/<context>')`
|
|
187
|
+
|
|
188
|
+
These are optional and ignored if missing.
|
package/dist/router.d.ts
CHANGED
|
@@ -1,18 +1,28 @@
|
|
|
1
|
-
type RouteHandler = (...args:
|
|
1
|
+
type RouteHandler = (...args: string[]) => void;
|
|
2
2
|
export type RoutingMode = 'history' | 'hash';
|
|
3
|
-
declare class Router {
|
|
3
|
+
export declare class Router {
|
|
4
4
|
private mode;
|
|
5
5
|
private routes;
|
|
6
6
|
private root;
|
|
7
|
+
private rootCompare;
|
|
7
8
|
private baseLocation;
|
|
8
9
|
staticFilters: ((url: string) => boolean)[];
|
|
10
|
+
private isListening;
|
|
11
|
+
private bound;
|
|
12
|
+
private notFoundHandler?;
|
|
13
|
+
private decodeParams;
|
|
9
14
|
constructor();
|
|
10
15
|
private cleanPathString;
|
|
11
16
|
private clearQuery;
|
|
12
17
|
private isStaticFile;
|
|
13
18
|
resetRoot(root: string): void;
|
|
14
19
|
getLocation(): string;
|
|
15
|
-
add(pattern: RegExp | RouteHandler, handler?: RouteHandler): Router;
|
|
20
|
+
add(pattern: RegExp | string | RouteHandler, handler?: RouteHandler): Router;
|
|
21
|
+
onNotFound(handler: (path: string) => void): Router;
|
|
22
|
+
setDecodeParams(decode: boolean): Router;
|
|
23
|
+
getQueryParams(search?: string): Record<string, string>;
|
|
24
|
+
private isBrowser;
|
|
25
|
+
private compilePattern;
|
|
16
26
|
/**
|
|
17
27
|
*
|
|
18
28
|
* @param location
|
|
@@ -20,7 +30,12 @@ declare class Router {
|
|
|
20
30
|
*/
|
|
21
31
|
handleChange(location?: string): boolean;
|
|
22
32
|
listen(mode?: RoutingMode): void;
|
|
23
|
-
|
|
33
|
+
unlisten(): void;
|
|
34
|
+
navigate(path?: string, opts?: {
|
|
35
|
+
replace?: boolean;
|
|
36
|
+
}): boolean;
|
|
37
|
+
replace(path?: string): boolean;
|
|
24
38
|
}
|
|
25
39
|
export declare const router: Router;
|
|
40
|
+
export declare function createRouter(): Router;
|
|
26
41
|
export {};
|
package/dist/router.js
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
|
-
class Router {
|
|
1
|
+
export class Router {
|
|
2
2
|
mode = 'hash';
|
|
3
3
|
routes = [];
|
|
4
4
|
root = '/';
|
|
5
|
+
rootCompare = '';
|
|
5
6
|
baseLocation = null;
|
|
6
7
|
staticFilters = [];
|
|
8
|
+
isListening = false;
|
|
9
|
+
bound = {};
|
|
10
|
+
notFoundHandler;
|
|
11
|
+
decodeParams = false;
|
|
7
12
|
constructor() {
|
|
8
13
|
this.staticFilters.push(url => {
|
|
9
14
|
const staticFileExtensions = ['.json', '.css', '.js', '.png', '.jpg', '.svg', '.webp', '.md', '.ejs', '.jsm', '.txt'];
|
|
@@ -25,13 +30,23 @@ class Router {
|
|
|
25
30
|
return (this.staticFilters || []).some(filter => filter(url));
|
|
26
31
|
}
|
|
27
32
|
resetRoot(root) {
|
|
28
|
-
|
|
33
|
+
const cleaned = this.cleanPathString(root);
|
|
34
|
+
this.root = '/' + cleaned + '/';
|
|
35
|
+
this.rootCompare = cleaned ? cleaned + '/' : '';
|
|
29
36
|
}
|
|
30
37
|
getLocation() {
|
|
38
|
+
if (!this.isBrowser())
|
|
39
|
+
return '';
|
|
31
40
|
if (this.mode === 'history') {
|
|
32
|
-
let fragment =
|
|
41
|
+
let fragment = decodeURI(window.location.pathname + window.location.search);
|
|
33
42
|
fragment = this.clearQuery(fragment);
|
|
34
|
-
|
|
43
|
+
// strip leading slash for comparison convenience
|
|
44
|
+
fragment = fragment.replace(/^\//, '');
|
|
45
|
+
if (this.root !== '/' && this.rootCompare && fragment.startsWith(this.rootCompare)) {
|
|
46
|
+
fragment = fragment.slice(this.rootCompare.length);
|
|
47
|
+
}
|
|
48
|
+
fragment = this.cleanPathString(fragment);
|
|
49
|
+
return fragment;
|
|
35
50
|
}
|
|
36
51
|
else {
|
|
37
52
|
const match = window.location.href.match(/#(.*)$/);
|
|
@@ -39,77 +54,207 @@ class Router {
|
|
|
39
54
|
}
|
|
40
55
|
}
|
|
41
56
|
add(pattern, handler) {
|
|
57
|
+
let paramNames;
|
|
42
58
|
if (typeof pattern === 'function') {
|
|
43
59
|
handler = pattern;
|
|
44
60
|
pattern = /^.*$/; // Match any path
|
|
45
61
|
}
|
|
46
|
-
|
|
62
|
+
else if (typeof pattern === 'string') {
|
|
63
|
+
const compiled = this.compilePattern(pattern);
|
|
64
|
+
pattern = compiled.regex;
|
|
65
|
+
paramNames = compiled.paramNames;
|
|
66
|
+
}
|
|
67
|
+
this.routes.push({ pattern: pattern, handler: handler, paramNames });
|
|
68
|
+
return this;
|
|
69
|
+
}
|
|
70
|
+
onNotFound(handler) {
|
|
71
|
+
this.notFoundHandler = handler;
|
|
72
|
+
return this;
|
|
73
|
+
}
|
|
74
|
+
setDecodeParams(decode) {
|
|
75
|
+
this.decodeParams = decode;
|
|
47
76
|
return this;
|
|
48
77
|
}
|
|
78
|
+
getQueryParams(search) {
|
|
79
|
+
if (!this.isBrowser() && !search)
|
|
80
|
+
return {};
|
|
81
|
+
const qs = typeof search === 'string' ? search : window.location.search || '';
|
|
82
|
+
const usp = new URLSearchParams(qs);
|
|
83
|
+
const obj = {};
|
|
84
|
+
usp.forEach((v, k) => { obj[k] = v; });
|
|
85
|
+
return obj;
|
|
86
|
+
}
|
|
87
|
+
isBrowser() {
|
|
88
|
+
return typeof window !== 'undefined' && typeof document !== 'undefined';
|
|
89
|
+
}
|
|
90
|
+
compilePattern(pattern) {
|
|
91
|
+
// normalize leading slash to align with cleanPathString behavior
|
|
92
|
+
const normalized = pattern.replace(/^\//, '');
|
|
93
|
+
const paramNames = [];
|
|
94
|
+
// convert /users/:id -> ^users/([^/]+)$
|
|
95
|
+
const reStr = normalized
|
|
96
|
+
.replace(/([.*+?^${}()|[\]\\])/g, '\\$1') // escape regex specials
|
|
97
|
+
.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, (_m, p1) => {
|
|
98
|
+
paramNames.push(p1);
|
|
99
|
+
return '([^/]+)';
|
|
100
|
+
});
|
|
101
|
+
return { regex: new RegExp(`^${reStr}$`), paramNames };
|
|
102
|
+
}
|
|
49
103
|
/**
|
|
50
104
|
*
|
|
51
105
|
* @param location
|
|
52
106
|
* @return true if it was intercepted or false if not handled
|
|
53
107
|
*/
|
|
54
108
|
handleChange(location) {
|
|
55
|
-
const path = location
|
|
109
|
+
const path = (location ?? this.getLocation()) || '';
|
|
56
110
|
if (this.isStaticFile(path))
|
|
57
111
|
return false; // Bypass routing for static files
|
|
58
112
|
for (const route of this.routes) {
|
|
59
|
-
const match = path.match(route.pattern);
|
|
113
|
+
const match = route.pattern ? path.match(route.pattern) : null;
|
|
60
114
|
if (match) {
|
|
61
115
|
match.shift(); // Remove the full match element
|
|
62
|
-
const queryParams =
|
|
63
|
-
|
|
116
|
+
const queryParams = this.getQueryParams();
|
|
117
|
+
const captures = this.decodeParams ? match.map(v => safeDecode(v)) : match;
|
|
118
|
+
let params;
|
|
119
|
+
if (route.paramNames && route.paramNames.length) {
|
|
120
|
+
params = {};
|
|
121
|
+
route.paramNames.forEach((n, i) => params[n] = captures[i]);
|
|
122
|
+
}
|
|
123
|
+
route.handler.call({ queryParams, params }, ...captures);
|
|
64
124
|
return true;
|
|
65
125
|
}
|
|
66
126
|
}
|
|
67
|
-
|
|
127
|
+
if (this.notFoundHandler) {
|
|
128
|
+
this.notFoundHandler(path);
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
if (path)
|
|
132
|
+
console.warn(`No routing found for ${path}`);
|
|
68
133
|
return false;
|
|
69
134
|
}
|
|
70
135
|
listen(mode = 'hash') {
|
|
136
|
+
if (!this.isBrowser())
|
|
137
|
+
return;
|
|
138
|
+
// avoid duplicate listeners
|
|
139
|
+
if (this.isListening)
|
|
140
|
+
this.unlisten();
|
|
71
141
|
const self = this;
|
|
72
142
|
this.mode = mode;
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
break;
|
|
77
|
-
case "history":
|
|
78
|
-
window.addEventListener('popstate', event => handler(event.state?.path));
|
|
79
|
-
document.addEventListener('click', handleInternalNavigation);
|
|
80
|
-
document.addEventListener('keydown', event => {
|
|
81
|
-
// @ts-ignore
|
|
82
|
-
if (event.key === 'Enter' && event.target.tagName === 'A')
|
|
83
|
-
handleInternalNavigation(event);
|
|
84
|
-
});
|
|
85
|
-
}
|
|
86
|
-
function handleInternalNavigation(event) {
|
|
87
|
-
const node = event.target;
|
|
88
|
-
const href = node.getAttribute('href');
|
|
89
|
-
if (href) {
|
|
90
|
-
event.preventDefault();
|
|
91
|
-
history.pushState({ path: href }, '', href);
|
|
92
|
-
handler(href);
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
function handler(path) {
|
|
96
|
-
path = path || location.href.split('#')[0];
|
|
97
|
-
if (self.isStaticFile(path))
|
|
143
|
+
const handler = (path) => {
|
|
144
|
+
const p = path || location.href.split('#')[0];
|
|
145
|
+
if (self.isStaticFile(p))
|
|
98
146
|
return;
|
|
99
147
|
const currentLocation = self.getLocation();
|
|
100
148
|
if (self.baseLocation !== currentLocation) {
|
|
101
149
|
self.baseLocation = currentLocation;
|
|
102
150
|
self.handleChange(currentLocation);
|
|
103
151
|
}
|
|
152
|
+
};
|
|
153
|
+
const handleInternalNavigation = (event) => {
|
|
154
|
+
// modified clicks or non-left clicks
|
|
155
|
+
const me = event;
|
|
156
|
+
if (event.type === 'click') {
|
|
157
|
+
if (me.button !== 0)
|
|
158
|
+
return; // left-click only
|
|
159
|
+
if (me.metaKey || me.ctrlKey || me.shiftKey)
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
const target = event.target;
|
|
163
|
+
if (!target || !('closest' in target))
|
|
164
|
+
return;
|
|
165
|
+
const anchor = target.closest?.('a');
|
|
166
|
+
if (!anchor)
|
|
167
|
+
return;
|
|
168
|
+
if (event.type === 'keydown') {
|
|
169
|
+
const ke = event;
|
|
170
|
+
if (ke.key !== 'Enter')
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
const href = anchor.getAttribute('href') || '';
|
|
174
|
+
if (!href)
|
|
175
|
+
return;
|
|
176
|
+
const url = new URL(href, window.location.href);
|
|
177
|
+
// ignore external origins or different hostnames
|
|
178
|
+
if (url.origin !== window.location.origin)
|
|
179
|
+
return;
|
|
180
|
+
// ignore target=_blank, download, or rel=noreferrer
|
|
181
|
+
const t = anchor.getAttribute('target');
|
|
182
|
+
if (t && t.toLowerCase() === '_blank')
|
|
183
|
+
return;
|
|
184
|
+
if (anchor.hasAttribute('download'))
|
|
185
|
+
return;
|
|
186
|
+
const rel = anchor.getAttribute('rel');
|
|
187
|
+
if (rel && /\bnoreferrer\b/i.test(rel))
|
|
188
|
+
return;
|
|
189
|
+
const pathWithQuery = url.pathname + (url.search || '');
|
|
190
|
+
if (self.isStaticFile(pathWithQuery) || self.isStaticFile(url.pathname))
|
|
191
|
+
return;
|
|
192
|
+
event.preventDefault();
|
|
193
|
+
history.pushState({ path: pathWithQuery }, '', pathWithQuery);
|
|
194
|
+
handler(pathWithQuery);
|
|
195
|
+
};
|
|
196
|
+
switch (mode) {
|
|
197
|
+
case 'hash':
|
|
198
|
+
this.bound.hashchange = () => handler();
|
|
199
|
+
window.addEventListener('hashchange', this.bound.hashchange);
|
|
200
|
+
break;
|
|
201
|
+
case 'history':
|
|
202
|
+
this.bound.popstate = (event) => handler(event.state?.path);
|
|
203
|
+
this.bound.click = handleInternalNavigation;
|
|
204
|
+
this.bound.keydown = (event) => handleInternalNavigation(event);
|
|
205
|
+
window.addEventListener('popstate', this.bound.popstate);
|
|
206
|
+
document.addEventListener('click', this.bound.click);
|
|
207
|
+
document.addEventListener('keydown', this.bound.keydown);
|
|
208
|
+
break;
|
|
104
209
|
}
|
|
210
|
+
this.isListening = true;
|
|
105
211
|
handler();
|
|
106
212
|
}
|
|
107
|
-
|
|
108
|
-
if (this.
|
|
109
|
-
|
|
213
|
+
unlisten() {
|
|
214
|
+
if (!this.isBrowser() || !this.isListening)
|
|
215
|
+
return;
|
|
216
|
+
switch (this.mode) {
|
|
217
|
+
case 'hash':
|
|
218
|
+
if (this.bound.hashchange)
|
|
219
|
+
window.removeEventListener('hashchange', this.bound.hashchange);
|
|
220
|
+
break;
|
|
221
|
+
case 'history':
|
|
222
|
+
if (this.bound.popstate)
|
|
223
|
+
window.removeEventListener('popstate', this.bound.popstate);
|
|
224
|
+
if (this.bound.click)
|
|
225
|
+
document.removeEventListener('click', this.bound.click);
|
|
226
|
+
if (this.bound.keydown)
|
|
227
|
+
document.removeEventListener('keydown', this.bound.keydown);
|
|
228
|
+
break;
|
|
229
|
+
}
|
|
230
|
+
this.bound = {};
|
|
231
|
+
this.isListening = false;
|
|
232
|
+
}
|
|
233
|
+
navigate(path = '', opts) {
|
|
234
|
+
if (!this.isBrowser())
|
|
235
|
+
return false;
|
|
236
|
+
if (this.mode === 'history') {
|
|
237
|
+
const url = this.root + this.cleanPathString(path);
|
|
238
|
+
if (opts?.replace)
|
|
239
|
+
history.replaceState(null, '', url);
|
|
240
|
+
else
|
|
241
|
+
history.pushState(null, '', url);
|
|
242
|
+
}
|
|
110
243
|
else
|
|
111
244
|
window.location.hash = this.cleanPathString(path);
|
|
112
245
|
return this.handleChange();
|
|
113
246
|
}
|
|
247
|
+
replace(path = '') {
|
|
248
|
+
return this.navigate(path, { replace: true });
|
|
249
|
+
}
|
|
114
250
|
}
|
|
115
251
|
export const router = new Router();
|
|
252
|
+
export function createRouter() { return new Router(); }
|
|
253
|
+
function safeDecode(v) {
|
|
254
|
+
try {
|
|
255
|
+
return decodeURIComponent(v);
|
|
256
|
+
}
|
|
257
|
+
catch {
|
|
258
|
+
return v;
|
|
259
|
+
}
|
|
260
|
+
}
|
package/dist/state-manager.d.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { RoutingMode } from "./router";
|
|
1
|
+
import { RoutingMode, Router as RouterType } from "./router";
|
|
2
2
|
import { IPubSubHandle, PubSubEvent } from "@dharmax/pubsub";
|
|
3
3
|
export type ApplicationStateName = string;
|
|
4
4
|
export type ApplicationState = {
|
|
5
5
|
name: ApplicationStateName;
|
|
6
6
|
pageName: string;
|
|
7
|
-
route: RegExp;
|
|
7
|
+
route: RegExp | string;
|
|
8
8
|
mode?: string | string[];
|
|
9
9
|
};
|
|
10
10
|
export type ChangeAuthority = (state: ApplicationState) => Promise<boolean>;
|
|
@@ -16,13 +16,17 @@ export declare class StateManager {
|
|
|
16
16
|
private stateContext;
|
|
17
17
|
static dispatcher: import("@dharmax/pubsub").PubSub;
|
|
18
18
|
private changeAuthorities;
|
|
19
|
-
|
|
19
|
+
private router;
|
|
20
|
+
private beforeChangeHandlers;
|
|
21
|
+
private afterChangeHandlers;
|
|
22
|
+
constructor(mode?: RoutingMode, autostart?: boolean, routerInstance?: RouterType);
|
|
20
23
|
start(): void;
|
|
24
|
+
stop(): void;
|
|
21
25
|
onChange(handler: (event: PubSubEvent, data: any) => void): IPubSubHandle;
|
|
22
26
|
registerChangeAuthority(authorityCallback: (targetState: ApplicationState) => Promise<boolean>): void;
|
|
23
27
|
getState(): ApplicationState;
|
|
24
28
|
get previous(): ApplicationState;
|
|
25
|
-
get context():
|
|
29
|
+
get context(): any;
|
|
26
30
|
/**
|
|
27
31
|
* set current page state
|
|
28
32
|
* @param state can be either just a state or a state and context (which can be sub-state, or anything else)
|
|
@@ -45,4 +49,8 @@ export declare class StateManager {
|
|
|
45
49
|
*/
|
|
46
50
|
addState(name: string, pageName?: string, route?: RegExp | string, mode?: string | string[]): void;
|
|
47
51
|
registerStateByState(state: ApplicationState): void;
|
|
52
|
+
onBeforeChange(handler: (target: ApplicationState, context?: any) => boolean | Promise<boolean>): void;
|
|
53
|
+
onAfterChange(handler: (state: ApplicationState, context?: any, previous?: ApplicationState) => void | Promise<void>): void;
|
|
54
|
+
onNotFound(handler: (path: string) => void): void;
|
|
48
55
|
}
|
|
56
|
+
export declare function createStateManager(mode?: RoutingMode, autostart?: boolean, routerInstance?: RouterType): StateManager;
|