@dharmax/state-router 3.1.2 → 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 +18 -3
- 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/dist/state-manager.js
CHANGED
|
@@ -8,13 +8,22 @@ export class StateManager {
|
|
|
8
8
|
stateContext;
|
|
9
9
|
static dispatcher = dispatcher;
|
|
10
10
|
changeAuthorities = [];
|
|
11
|
-
|
|
11
|
+
router;
|
|
12
|
+
beforeChangeHandlers = [];
|
|
13
|
+
afterChangeHandlers = [];
|
|
14
|
+
constructor(mode = 'hash', autostart = true, routerInstance = router) {
|
|
12
15
|
this.mode = mode;
|
|
16
|
+
this.router = routerInstance;
|
|
13
17
|
if (autostart)
|
|
14
|
-
router.listen(mode);
|
|
18
|
+
this.router.listen(mode);
|
|
15
19
|
}
|
|
16
20
|
start() {
|
|
17
|
-
router.listen(this.mode);
|
|
21
|
+
this.router.listen(this.mode);
|
|
22
|
+
}
|
|
23
|
+
stop() {
|
|
24
|
+
// unlisten router to cleanup event listeners
|
|
25
|
+
// @ts-ignore - older Router versions may not have unlisten
|
|
26
|
+
this.router.unlisten && this.router.unlisten();
|
|
18
27
|
}
|
|
19
28
|
onChange(handler) {
|
|
20
29
|
return StateManager.dispatcher.on('state:changed', handler);
|
|
@@ -50,9 +59,9 @@ export class StateManager {
|
|
|
50
59
|
}
|
|
51
60
|
/** attempts to restore state from current url. Currently, works only in hash mode */
|
|
52
61
|
restoreState(defaultState) {
|
|
53
|
-
if (router.navigate(window.location.pathname))
|
|
62
|
+
if (this.router.navigate(window.location.pathname))
|
|
54
63
|
return;
|
|
55
|
-
router.navigate(defaultState);
|
|
64
|
+
this.router.navigate(defaultState);
|
|
56
65
|
}
|
|
57
66
|
/**
|
|
58
67
|
*
|
|
@@ -67,13 +76,18 @@ export class StateManager {
|
|
|
67
76
|
}
|
|
68
77
|
// check if the state change was declined by any change authority and if so - don't do it and return false
|
|
69
78
|
const changeConfirmations = await Promise.all(this.changeAuthorities.map(authority => authority(newState)));
|
|
70
|
-
|
|
79
|
+
const vetoes = await Promise.all(this.beforeChangeHandlers.map(h => Promise.resolve(h(newState, context))));
|
|
80
|
+
if (changeConfirmations.includes(false) || vetoes.includes(false))
|
|
71
81
|
return false;
|
|
72
82
|
// perform the change
|
|
73
83
|
this.previousState = this.appState;
|
|
74
84
|
this.stateContext = context;
|
|
75
85
|
this.appState = newState;
|
|
76
86
|
dispatcher.trigger('state-manager', 'state', 'changed', this.appState);
|
|
87
|
+
// afterChange hooks
|
|
88
|
+
for (const cb of this.afterChangeHandlers) {
|
|
89
|
+
await cb(this.appState, context, this.previousState);
|
|
90
|
+
}
|
|
77
91
|
return true;
|
|
78
92
|
}
|
|
79
93
|
/**
|
|
@@ -86,8 +100,9 @@ export class StateManager {
|
|
|
86
100
|
addState(name, pageName, route, mode) {
|
|
87
101
|
pageName = pageName || name;
|
|
88
102
|
route = route || pageName;
|
|
89
|
-
if (typeof route ===
|
|
90
|
-
|
|
103
|
+
if (typeof route === 'string' && route.includes('%')) {
|
|
104
|
+
// keep colon-based string patterns intact for Router to compile named params
|
|
105
|
+
const newRoute = route.split('%').join('?(.*)');
|
|
91
106
|
route = new RegExp(`^${newRoute}$`);
|
|
92
107
|
}
|
|
93
108
|
this.registerStateByState({
|
|
@@ -99,8 +114,13 @@ export class StateManager {
|
|
|
99
114
|
}
|
|
100
115
|
registerStateByState(state) {
|
|
101
116
|
this.allStates[state.name] = state;
|
|
102
|
-
|
|
103
|
-
|
|
117
|
+
const self = this;
|
|
118
|
+
this.router.add(state.route, async function (...captures) {
|
|
119
|
+
// prefer named params (from router string patterns), else multi-captures array, else first capture
|
|
120
|
+
// @ts-ignore
|
|
121
|
+
const named = (this && this.params) || null;
|
|
122
|
+
const context = named && Object.keys(named).length ? named : (captures.length <= 1 ? captures[0] : captures);
|
|
123
|
+
if (await self.setState(state.name, context)) {
|
|
104
124
|
// @ts-ignore
|
|
105
125
|
window.pageChangeHandler && window.pageChangeHandler('send', 'pageview', `/${state.name}/${context || ''}`);
|
|
106
126
|
// @ts-ignore
|
|
@@ -108,4 +128,18 @@ export class StateManager {
|
|
|
108
128
|
}
|
|
109
129
|
});
|
|
110
130
|
}
|
|
131
|
+
onBeforeChange(handler) {
|
|
132
|
+
this.beforeChangeHandlers.push(handler);
|
|
133
|
+
}
|
|
134
|
+
onAfterChange(handler) {
|
|
135
|
+
this.afterChangeHandlers.push(handler);
|
|
136
|
+
}
|
|
137
|
+
onNotFound(handler) {
|
|
138
|
+
// proxy to router-level notFound
|
|
139
|
+
// @ts-ignore - older Router versions may not have onNotFound
|
|
140
|
+
this.router.onNotFound && this.router.onNotFound(handler);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
export function createStateManager(mode = 'hash', autostart = true, routerInstance = router) {
|
|
144
|
+
return new StateManager(mode, autostart, routerInstance);
|
|
111
145
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
14
|
+
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
|
+
};
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
__exportStar(require("./router"), exports);
|
|
18
|
+
__exportStar(require("./state-manager"), exports);
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.router = exports.Router = void 0;
|
|
4
|
+
exports.createRouter = createRouter;
|
|
5
|
+
class Router {
|
|
6
|
+
mode = 'hash';
|
|
7
|
+
routes = [];
|
|
8
|
+
root = '/';
|
|
9
|
+
rootCompare = '';
|
|
10
|
+
baseLocation = null;
|
|
11
|
+
staticFilters = [];
|
|
12
|
+
isListening = false;
|
|
13
|
+
bound = {};
|
|
14
|
+
notFoundHandler;
|
|
15
|
+
decodeParams = false;
|
|
16
|
+
constructor() {
|
|
17
|
+
this.staticFilters.push(url => {
|
|
18
|
+
const staticFileExtensions = ['.json', '.css', '.js', '.png', '.jpg', '.svg', '.webp', '.md', '.ejs', '.jsm', '.txt'];
|
|
19
|
+
return staticFileExtensions.some(ext => url.endsWith(ext));
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
cleanPathString(path) {
|
|
23
|
+
path = path.replace(/\/$/, '').replace(/^\//, '');
|
|
24
|
+
return path = path.replace(/#{2,}/g, '#');
|
|
25
|
+
}
|
|
26
|
+
clearQuery(url) {
|
|
27
|
+
const [path, query] = url.split('?');
|
|
28
|
+
if (!query)
|
|
29
|
+
return path;
|
|
30
|
+
const [_, hash] = query.split('#');
|
|
31
|
+
return hash ? `${path}#${hash}` : path;
|
|
32
|
+
}
|
|
33
|
+
isStaticFile(url) {
|
|
34
|
+
return (this.staticFilters || []).some(filter => filter(url));
|
|
35
|
+
}
|
|
36
|
+
resetRoot(root) {
|
|
37
|
+
const cleaned = this.cleanPathString(root);
|
|
38
|
+
this.root = '/' + cleaned + '/';
|
|
39
|
+
this.rootCompare = cleaned ? cleaned + '/' : '';
|
|
40
|
+
}
|
|
41
|
+
getLocation() {
|
|
42
|
+
if (!this.isBrowser())
|
|
43
|
+
return '';
|
|
44
|
+
if (this.mode === 'history') {
|
|
45
|
+
let fragment = decodeURI(window.location.pathname + window.location.search);
|
|
46
|
+
fragment = this.clearQuery(fragment);
|
|
47
|
+
// strip leading slash for comparison convenience
|
|
48
|
+
fragment = fragment.replace(/^\//, '');
|
|
49
|
+
if (this.root !== '/' && this.rootCompare && fragment.startsWith(this.rootCompare)) {
|
|
50
|
+
fragment = fragment.slice(this.rootCompare.length);
|
|
51
|
+
}
|
|
52
|
+
fragment = this.cleanPathString(fragment);
|
|
53
|
+
return fragment;
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
const match = window.location.href.match(/#(.*)$/);
|
|
57
|
+
return match ? this.clearQuery(match[1]) : '';
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
add(pattern, handler) {
|
|
61
|
+
let paramNames;
|
|
62
|
+
if (typeof pattern === 'function') {
|
|
63
|
+
handler = pattern;
|
|
64
|
+
pattern = /^.*$/; // Match any path
|
|
65
|
+
}
|
|
66
|
+
else if (typeof pattern === 'string') {
|
|
67
|
+
const compiled = this.compilePattern(pattern);
|
|
68
|
+
pattern = compiled.regex;
|
|
69
|
+
paramNames = compiled.paramNames;
|
|
70
|
+
}
|
|
71
|
+
this.routes.push({ pattern: pattern, handler: handler, paramNames });
|
|
72
|
+
return this;
|
|
73
|
+
}
|
|
74
|
+
onNotFound(handler) {
|
|
75
|
+
this.notFoundHandler = handler;
|
|
76
|
+
return this;
|
|
77
|
+
}
|
|
78
|
+
setDecodeParams(decode) {
|
|
79
|
+
this.decodeParams = decode;
|
|
80
|
+
return this;
|
|
81
|
+
}
|
|
82
|
+
getQueryParams(search) {
|
|
83
|
+
if (!this.isBrowser() && !search)
|
|
84
|
+
return {};
|
|
85
|
+
const qs = typeof search === 'string' ? search : window.location.search || '';
|
|
86
|
+
const usp = new URLSearchParams(qs);
|
|
87
|
+
const obj = {};
|
|
88
|
+
usp.forEach((v, k) => { obj[k] = v; });
|
|
89
|
+
return obj;
|
|
90
|
+
}
|
|
91
|
+
isBrowser() {
|
|
92
|
+
return typeof window !== 'undefined' && typeof document !== 'undefined';
|
|
93
|
+
}
|
|
94
|
+
compilePattern(pattern) {
|
|
95
|
+
// normalize leading slash to align with cleanPathString behavior
|
|
96
|
+
const normalized = pattern.replace(/^\//, '');
|
|
97
|
+
const paramNames = [];
|
|
98
|
+
// convert /users/:id -> ^users/([^/]+)$
|
|
99
|
+
const reStr = normalized
|
|
100
|
+
.replace(/([.*+?^${}()|[\]\\])/g, '\\$1') // escape regex specials
|
|
101
|
+
.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, (_m, p1) => {
|
|
102
|
+
paramNames.push(p1);
|
|
103
|
+
return '([^/]+)';
|
|
104
|
+
});
|
|
105
|
+
return { regex: new RegExp(`^${reStr}$`), paramNames };
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
*
|
|
109
|
+
* @param location
|
|
110
|
+
* @return true if it was intercepted or false if not handled
|
|
111
|
+
*/
|
|
112
|
+
handleChange(location) {
|
|
113
|
+
const path = (location ?? this.getLocation()) || '';
|
|
114
|
+
if (this.isStaticFile(path))
|
|
115
|
+
return false; // Bypass routing for static files
|
|
116
|
+
for (const route of this.routes) {
|
|
117
|
+
const match = route.pattern ? path.match(route.pattern) : null;
|
|
118
|
+
if (match) {
|
|
119
|
+
match.shift(); // Remove the full match element
|
|
120
|
+
const queryParams = this.getQueryParams();
|
|
121
|
+
const captures = this.decodeParams ? match.map(v => safeDecode(v)) : match;
|
|
122
|
+
let params;
|
|
123
|
+
if (route.paramNames && route.paramNames.length) {
|
|
124
|
+
params = {};
|
|
125
|
+
route.paramNames.forEach((n, i) => params[n] = captures[i]);
|
|
126
|
+
}
|
|
127
|
+
route.handler.call({ queryParams, params }, ...captures);
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if (this.notFoundHandler) {
|
|
132
|
+
this.notFoundHandler(path);
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
if (path)
|
|
136
|
+
console.warn(`No routing found for ${path}`);
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
listen(mode = 'hash') {
|
|
140
|
+
if (!this.isBrowser())
|
|
141
|
+
return;
|
|
142
|
+
// avoid duplicate listeners
|
|
143
|
+
if (this.isListening)
|
|
144
|
+
this.unlisten();
|
|
145
|
+
const self = this;
|
|
146
|
+
this.mode = mode;
|
|
147
|
+
const handler = (path) => {
|
|
148
|
+
const p = path || location.href.split('#')[0];
|
|
149
|
+
if (self.isStaticFile(p))
|
|
150
|
+
return;
|
|
151
|
+
const currentLocation = self.getLocation();
|
|
152
|
+
if (self.baseLocation !== currentLocation) {
|
|
153
|
+
self.baseLocation = currentLocation;
|
|
154
|
+
self.handleChange(currentLocation);
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
const handleInternalNavigation = (event) => {
|
|
158
|
+
// modified clicks or non-left clicks
|
|
159
|
+
const me = event;
|
|
160
|
+
if (event.type === 'click') {
|
|
161
|
+
if (me.button !== 0)
|
|
162
|
+
return; // left-click only
|
|
163
|
+
if (me.metaKey || me.ctrlKey || me.shiftKey)
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
const target = event.target;
|
|
167
|
+
if (!target || !('closest' in target))
|
|
168
|
+
return;
|
|
169
|
+
const anchor = target.closest?.('a');
|
|
170
|
+
if (!anchor)
|
|
171
|
+
return;
|
|
172
|
+
if (event.type === 'keydown') {
|
|
173
|
+
const ke = event;
|
|
174
|
+
if (ke.key !== 'Enter')
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
const href = anchor.getAttribute('href') || '';
|
|
178
|
+
if (!href)
|
|
179
|
+
return;
|
|
180
|
+
const url = new URL(href, window.location.href);
|
|
181
|
+
// ignore external origins or different hostnames
|
|
182
|
+
if (url.origin !== window.location.origin)
|
|
183
|
+
return;
|
|
184
|
+
// ignore target=_blank, download, or rel=noreferrer
|
|
185
|
+
const t = anchor.getAttribute('target');
|
|
186
|
+
if (t && t.toLowerCase() === '_blank')
|
|
187
|
+
return;
|
|
188
|
+
if (anchor.hasAttribute('download'))
|
|
189
|
+
return;
|
|
190
|
+
const rel = anchor.getAttribute('rel');
|
|
191
|
+
if (rel && /\bnoreferrer\b/i.test(rel))
|
|
192
|
+
return;
|
|
193
|
+
const pathWithQuery = url.pathname + (url.search || '');
|
|
194
|
+
if (self.isStaticFile(pathWithQuery) || self.isStaticFile(url.pathname))
|
|
195
|
+
return;
|
|
196
|
+
event.preventDefault();
|
|
197
|
+
history.pushState({ path: pathWithQuery }, '', pathWithQuery);
|
|
198
|
+
handler(pathWithQuery);
|
|
199
|
+
};
|
|
200
|
+
switch (mode) {
|
|
201
|
+
case 'hash':
|
|
202
|
+
this.bound.hashchange = () => handler();
|
|
203
|
+
window.addEventListener('hashchange', this.bound.hashchange);
|
|
204
|
+
break;
|
|
205
|
+
case 'history':
|
|
206
|
+
this.bound.popstate = (event) => handler(event.state?.path);
|
|
207
|
+
this.bound.click = handleInternalNavigation;
|
|
208
|
+
this.bound.keydown = (event) => handleInternalNavigation(event);
|
|
209
|
+
window.addEventListener('popstate', this.bound.popstate);
|
|
210
|
+
document.addEventListener('click', this.bound.click);
|
|
211
|
+
document.addEventListener('keydown', this.bound.keydown);
|
|
212
|
+
break;
|
|
213
|
+
}
|
|
214
|
+
this.isListening = true;
|
|
215
|
+
handler();
|
|
216
|
+
}
|
|
217
|
+
unlisten() {
|
|
218
|
+
if (!this.isBrowser() || !this.isListening)
|
|
219
|
+
return;
|
|
220
|
+
switch (this.mode) {
|
|
221
|
+
case 'hash':
|
|
222
|
+
if (this.bound.hashchange)
|
|
223
|
+
window.removeEventListener('hashchange', this.bound.hashchange);
|
|
224
|
+
break;
|
|
225
|
+
case 'history':
|
|
226
|
+
if (this.bound.popstate)
|
|
227
|
+
window.removeEventListener('popstate', this.bound.popstate);
|
|
228
|
+
if (this.bound.click)
|
|
229
|
+
document.removeEventListener('click', this.bound.click);
|
|
230
|
+
if (this.bound.keydown)
|
|
231
|
+
document.removeEventListener('keydown', this.bound.keydown);
|
|
232
|
+
break;
|
|
233
|
+
}
|
|
234
|
+
this.bound = {};
|
|
235
|
+
this.isListening = false;
|
|
236
|
+
}
|
|
237
|
+
navigate(path = '', opts) {
|
|
238
|
+
if (!this.isBrowser())
|
|
239
|
+
return false;
|
|
240
|
+
if (this.mode === 'history') {
|
|
241
|
+
const url = this.root + this.cleanPathString(path);
|
|
242
|
+
if (opts?.replace)
|
|
243
|
+
history.replaceState(null, '', url);
|
|
244
|
+
else
|
|
245
|
+
history.pushState(null, '', url);
|
|
246
|
+
}
|
|
247
|
+
else
|
|
248
|
+
window.location.hash = this.cleanPathString(path);
|
|
249
|
+
return this.handleChange();
|
|
250
|
+
}
|
|
251
|
+
replace(path = '') {
|
|
252
|
+
return this.navigate(path, { replace: true });
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
exports.Router = Router;
|
|
256
|
+
exports.router = new Router();
|
|
257
|
+
function createRouter() { return new Router(); }
|
|
258
|
+
function safeDecode(v) {
|
|
259
|
+
try {
|
|
260
|
+
return decodeURIComponent(v);
|
|
261
|
+
}
|
|
262
|
+
catch {
|
|
263
|
+
return v;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
type RouteHandler = (...args: string[]) => void;
|
|
2
|
+
export type RoutingMode = 'history' | 'hash';
|
|
3
|
+
export declare class Router {
|
|
4
|
+
private mode;
|
|
5
|
+
private routes;
|
|
6
|
+
private root;
|
|
7
|
+
private rootCompare;
|
|
8
|
+
private baseLocation;
|
|
9
|
+
staticFilters: ((url: string) => boolean)[];
|
|
10
|
+
private isListening;
|
|
11
|
+
private bound;
|
|
12
|
+
private notFoundHandler?;
|
|
13
|
+
private decodeParams;
|
|
14
|
+
constructor();
|
|
15
|
+
private cleanPathString;
|
|
16
|
+
private clearQuery;
|
|
17
|
+
private isStaticFile;
|
|
18
|
+
resetRoot(root: string): void;
|
|
19
|
+
getLocation(): string;
|
|
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;
|
|
26
|
+
/**
|
|
27
|
+
*
|
|
28
|
+
* @param location
|
|
29
|
+
* @return true if it was intercepted or false if not handled
|
|
30
|
+
*/
|
|
31
|
+
handleChange(location?: string): boolean;
|
|
32
|
+
listen(mode?: RoutingMode): void;
|
|
33
|
+
unlisten(): void;
|
|
34
|
+
navigate(path?: string, opts?: {
|
|
35
|
+
replace?: boolean;
|
|
36
|
+
}): boolean;
|
|
37
|
+
replace(path?: string): boolean;
|
|
38
|
+
}
|
|
39
|
+
export declare const router: Router;
|
|
40
|
+
export declare function createRouter(): Router;
|
|
41
|
+
export {};
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.StateManager = void 0;
|
|
4
|
+
exports.createStateManager = createStateManager;
|
|
5
|
+
const router_1 = require("./router");
|
|
6
|
+
const pubsub_1 = require("@dharmax/pubsub");
|
|
7
|
+
class StateManager {
|
|
8
|
+
mode;
|
|
9
|
+
allStates = {};
|
|
10
|
+
appState;
|
|
11
|
+
previousState;
|
|
12
|
+
stateContext;
|
|
13
|
+
static dispatcher = pubsub_1.default;
|
|
14
|
+
changeAuthorities = [];
|
|
15
|
+
router;
|
|
16
|
+
beforeChangeHandlers = [];
|
|
17
|
+
afterChangeHandlers = [];
|
|
18
|
+
constructor(mode = 'hash', autostart = true, routerInstance = router_1.router) {
|
|
19
|
+
this.mode = mode;
|
|
20
|
+
this.router = routerInstance;
|
|
21
|
+
if (autostart)
|
|
22
|
+
this.router.listen(mode);
|
|
23
|
+
}
|
|
24
|
+
start() {
|
|
25
|
+
this.router.listen(this.mode);
|
|
26
|
+
}
|
|
27
|
+
stop() {
|
|
28
|
+
// unlisten router to cleanup event listeners
|
|
29
|
+
// @ts-ignore - older Router versions may not have unlisten
|
|
30
|
+
this.router.unlisten && this.router.unlisten();
|
|
31
|
+
}
|
|
32
|
+
onChange(handler) {
|
|
33
|
+
return StateManager.dispatcher.on('state:changed', handler);
|
|
34
|
+
}
|
|
35
|
+
/*
|
|
36
|
+
Add a hook which enable conditional approval of state change. It can be more than one; when a state
|
|
37
|
+
change is requested, all the registered authorities must return true (asynchronously) otherwise the change
|
|
38
|
+
requested doesn't happen.
|
|
39
|
+
**/
|
|
40
|
+
registerChangeAuthority(authorityCallback) {
|
|
41
|
+
this.changeAuthorities.push(authorityCallback);
|
|
42
|
+
}
|
|
43
|
+
getState() {
|
|
44
|
+
return this.appState || {};
|
|
45
|
+
}
|
|
46
|
+
get previous() {
|
|
47
|
+
return this.previousState;
|
|
48
|
+
}
|
|
49
|
+
get context() {
|
|
50
|
+
return this.stateContext;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* set current page state
|
|
54
|
+
* @param state can be either just a state or a state and context (which can be sub-state, or anything else)
|
|
55
|
+
*/
|
|
56
|
+
set state(state) {
|
|
57
|
+
if (Array.isArray(state)) {
|
|
58
|
+
const sName = state.shift();
|
|
59
|
+
this.setState(sName, state);
|
|
60
|
+
}
|
|
61
|
+
else
|
|
62
|
+
this.setState(state);
|
|
63
|
+
}
|
|
64
|
+
/** attempts to restore state from current url. Currently, works only in hash mode */
|
|
65
|
+
restoreState(defaultState) {
|
|
66
|
+
if (this.router.navigate(window.location.pathname))
|
|
67
|
+
return;
|
|
68
|
+
this.router.navigate(defaultState);
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
*
|
|
72
|
+
* @param stateName state
|
|
73
|
+
* @param context extra context (e.g. sub-state)
|
|
74
|
+
*/
|
|
75
|
+
async setState(stateName, context) {
|
|
76
|
+
const newState = this.allStates[stateName];
|
|
77
|
+
if (!newState) {
|
|
78
|
+
alert(`Undefined app state ${stateName}`);
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
// check if the state change was declined by any change authority and if so - don't do it and return false
|
|
82
|
+
const changeConfirmations = await Promise.all(this.changeAuthorities.map(authority => authority(newState)));
|
|
83
|
+
const vetoes = await Promise.all(this.beforeChangeHandlers.map(h => Promise.resolve(h(newState, context))));
|
|
84
|
+
if (changeConfirmations.includes(false) || vetoes.includes(false))
|
|
85
|
+
return false;
|
|
86
|
+
// perform the change
|
|
87
|
+
this.previousState = this.appState;
|
|
88
|
+
this.stateContext = context;
|
|
89
|
+
this.appState = newState;
|
|
90
|
+
pubsub_1.default.trigger('state-manager', 'state', 'changed', this.appState);
|
|
91
|
+
// afterChange hooks
|
|
92
|
+
for (const cb of this.afterChangeHandlers) {
|
|
93
|
+
await cb(this.appState, context, this.previousState);
|
|
94
|
+
}
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Define an application state
|
|
99
|
+
* @param name
|
|
100
|
+
* @param pageName by default it equals the name (you can null it)
|
|
101
|
+
* @param route by default it equals the pageName (ditto)
|
|
102
|
+
* @param mode optional
|
|
103
|
+
*/
|
|
104
|
+
addState(name, pageName, route, mode) {
|
|
105
|
+
pageName = pageName || name;
|
|
106
|
+
route = route || pageName;
|
|
107
|
+
if (typeof route === 'string' && route.includes('%')) {
|
|
108
|
+
// keep colon-based string patterns intact for Router to compile named params
|
|
109
|
+
const newRoute = route.split('%').join('?(.*)');
|
|
110
|
+
route = new RegExp(`^${newRoute}$`);
|
|
111
|
+
}
|
|
112
|
+
this.registerStateByState({
|
|
113
|
+
name,
|
|
114
|
+
pageName,
|
|
115
|
+
route,
|
|
116
|
+
mode
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
registerStateByState(state) {
|
|
120
|
+
this.allStates[state.name] = state;
|
|
121
|
+
const self = this;
|
|
122
|
+
this.router.add(state.route, async function (...captures) {
|
|
123
|
+
// prefer named params (from router string patterns), else multi-captures array, else first capture
|
|
124
|
+
// @ts-ignore
|
|
125
|
+
const named = (this && this.params) || null;
|
|
126
|
+
const context = named && Object.keys(named).length ? named : (captures.length <= 1 ? captures[0] : captures);
|
|
127
|
+
if (await self.setState(state.name, context)) {
|
|
128
|
+
// @ts-ignore
|
|
129
|
+
window.pageChangeHandler && window.pageChangeHandler('send', 'pageview', `/${state.name}/${context || ''}`);
|
|
130
|
+
// @ts-ignore
|
|
131
|
+
window.ga && window.ga('send', 'pageview', `/${state.name}/${context || ''}`);
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
onBeforeChange(handler) {
|
|
136
|
+
this.beforeChangeHandlers.push(handler);
|
|
137
|
+
}
|
|
138
|
+
onAfterChange(handler) {
|
|
139
|
+
this.afterChangeHandlers.push(handler);
|
|
140
|
+
}
|
|
141
|
+
onNotFound(handler) {
|
|
142
|
+
// proxy to router-level notFound
|
|
143
|
+
// @ts-ignore - older Router versions may not have onNotFound
|
|
144
|
+
this.router.onNotFound && this.router.onNotFound(handler);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
exports.StateManager = StateManager;
|
|
148
|
+
function createStateManager(mode = 'hash', autostart = true, routerInstance = router_1.router) {
|
|
149
|
+
return new StateManager(mode, autostart, routerInstance);
|
|
150
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { RoutingMode, Router as RouterType } from "./router";
|
|
2
|
+
import { IPubSubHandle, PubSubEvent } from "@dharmax/pubsub";
|
|
3
|
+
export type ApplicationStateName = string;
|
|
4
|
+
export type ApplicationState = {
|
|
5
|
+
name: ApplicationStateName;
|
|
6
|
+
pageName: string;
|
|
7
|
+
route: RegExp | string;
|
|
8
|
+
mode?: string | string[];
|
|
9
|
+
};
|
|
10
|
+
export type ChangeAuthority = (state: ApplicationState) => Promise<boolean>;
|
|
11
|
+
export declare class StateManager {
|
|
12
|
+
private mode;
|
|
13
|
+
private allStates;
|
|
14
|
+
private appState;
|
|
15
|
+
private previousState;
|
|
16
|
+
private stateContext;
|
|
17
|
+
static dispatcher: import("@dharmax/pubsub").PubSub;
|
|
18
|
+
private changeAuthorities;
|
|
19
|
+
private router;
|
|
20
|
+
private beforeChangeHandlers;
|
|
21
|
+
private afterChangeHandlers;
|
|
22
|
+
constructor(mode?: RoutingMode, autostart?: boolean, routerInstance?: RouterType);
|
|
23
|
+
start(): void;
|
|
24
|
+
stop(): void;
|
|
25
|
+
onChange(handler: (event: PubSubEvent, data: any) => void): IPubSubHandle;
|
|
26
|
+
registerChangeAuthority(authorityCallback: (targetState: ApplicationState) => Promise<boolean>): void;
|
|
27
|
+
getState(): ApplicationState;
|
|
28
|
+
get previous(): ApplicationState;
|
|
29
|
+
get context(): any;
|
|
30
|
+
/**
|
|
31
|
+
* set current page state
|
|
32
|
+
* @param state can be either just a state or a state and context (which can be sub-state, or anything else)
|
|
33
|
+
*/
|
|
34
|
+
set state(state: ApplicationStateName | [ApplicationStateName, ...any]);
|
|
35
|
+
/** attempts to restore state from current url. Currently, works only in hash mode */
|
|
36
|
+
restoreState(defaultState: ApplicationStateName): void;
|
|
37
|
+
/**
|
|
38
|
+
*
|
|
39
|
+
* @param stateName state
|
|
40
|
+
* @param context extra context (e.g. sub-state)
|
|
41
|
+
*/
|
|
42
|
+
setState(stateName: ApplicationStateName, context?: any): Promise<boolean>;
|
|
43
|
+
/**
|
|
44
|
+
* Define an application state
|
|
45
|
+
* @param name
|
|
46
|
+
* @param pageName by default it equals the name (you can null it)
|
|
47
|
+
* @param route by default it equals the pageName (ditto)
|
|
48
|
+
* @param mode optional
|
|
49
|
+
*/
|
|
50
|
+
addState(name: string, pageName?: string, route?: RegExp | string, mode?: string | string[]): void;
|
|
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;
|
|
55
|
+
}
|
|
56
|
+
export declare function createStateManager(mode?: RoutingMode, autostart?: boolean, routerInstance?: RouterType): StateManager;
|
package/package.json
CHANGED
|
@@ -1,14 +1,27 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dharmax/state-router",
|
|
3
|
-
"version": "3.1
|
|
3
|
+
"version": "3.2.1",
|
|
4
4
|
"description": "A cute and tight router and application state controller",
|
|
5
|
-
"
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist-cjs/index.cjs",
|
|
7
|
+
"module": "dist/index.js",
|
|
6
8
|
"types": "dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/index.js",
|
|
13
|
+
"require": "./dist-cjs/index.cjs"
|
|
14
|
+
},
|
|
15
|
+
"./package.json": "./package.json"
|
|
16
|
+
},
|
|
7
17
|
|
|
8
18
|
"scripts": {
|
|
9
|
-
"test": "
|
|
10
|
-
"
|
|
11
|
-
"
|
|
19
|
+
"test": "vitest run --coverage",
|
|
20
|
+
"test:watch": "vitest",
|
|
21
|
+
"build": "npm run build:esm && npm run build:cjs",
|
|
22
|
+
"build:esm": "npx tsc",
|
|
23
|
+
"build:cjs": "npx tsc -p tsconfig.cjs.json && node scripts/rename-cjs.mjs",
|
|
24
|
+
"prepublishOnly": "npm run build"
|
|
12
25
|
},
|
|
13
26
|
"repository": {
|
|
14
27
|
"type": "git",
|
|
@@ -23,9 +36,18 @@
|
|
|
23
36
|
"bugs": {
|
|
24
37
|
"url": "https://github.com/dharmax/state-router/issues"
|
|
25
38
|
},
|
|
26
|
-
"homepage": "https://github.com/dharmax/state-
|
|
39
|
+
"homepage": "https://github.com/dharmax/state-router",
|
|
40
|
+
"sideEffects": false,
|
|
41
|
+
"files": [
|
|
42
|
+
"dist",
|
|
43
|
+
"dist-cjs",
|
|
44
|
+
"ReadMe.md"
|
|
45
|
+
],
|
|
27
46
|
"devDependencies": {
|
|
28
|
-
"typescript": "^
|
|
47
|
+
"typescript": "^5.9.2",
|
|
48
|
+
"vitest": "^2.0.5",
|
|
49
|
+
"jsdom": "^24.1.0",
|
|
50
|
+
"@vitest/coverage-v8": "^2.0.5"
|
|
29
51
|
},
|
|
30
52
|
"dependencies": {
|
|
31
53
|
"@dharmax/pubsub": "^1.1.0"
|