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