@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 CHANGED
@@ -1,57 +1,188 @@
1
1
 
2
- # General
3
- This package contains a functional router and a web-application state manager.
2
+ ## Overview
4
3
 
5
- What is a functional router? it's a router that captures a url changes and
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
- Together with the state manager - which will be the main object you'd need
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 router by default ignores file extensions '.json', '.css', '.js', '.png', '.jpg', '.svg', '.webp','md'
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
- you can use the hash notation or not (set the router's mode to 'history' or 'hash')
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
- # Example
15
+ Install as usual and build the TypeScript sources:
21
16
 
22
- ## state definitions
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
- ```javascript
25
- stateManager.addState('main', 'main-page', /main$/);
26
- stateManager.addState('login', 'login-box', 'login')
27
- stateManager.addState('signup', 'signup-box', /signup/)
28
- stateManager.addState('my-profile') // will assume the page name and the route are the same...
29
- stateManager.addState('inbox')
30
- stateManager.addState('about')
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
- ## usage in the gui
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
- In your main component, you write something like that:
100
+ - `setState(name, context?)`
101
+ - Programmatically set the state and optional context (e.g., a sub‑state or id).
39
102
 
40
- ```javascript
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
- stateManager.onChange( event => {
43
- // this specific example works with RiotJs, but you get the drift
44
- this.update({
45
- currentPage: event.data.pageName
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
- ## More
53
- * pageChangeHandler - it's a global optional page change listener that receives ('send', 'pageview', `/${state.name}/${context || ''}`);
54
- * google analytics (ga) is automatically used if it was found
55
- * query parameters are passed to the state context under queryParams object
56
- * on page change, an event state:changed is fired with the new state (that include the context)
57
- * in addState, the last (optional) parameter can contain either a string an array of strings and it can be used for in-state special logic, or sub-states within the same parent-component, for example
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,16 @@
1
- type RouteHandler = (...args: any[]) => void;
1
+ type RouteHandler = (...args: string[]) => void;
2
2
  export type RoutingMode = 'history' | 'hash';
3
- declare class Router {
4
- private mode;
5
- private routes;
6
- private root;
7
- private baseLocation;
3
+ export declare class Router {
4
+ #private;
8
5
  staticFilters: ((url: string) => boolean)[];
9
6
  constructor();
10
- private cleanPathString;
11
- private clearQuery;
12
- private isStaticFile;
13
7
  resetRoot(root: string): void;
8
+ setMode(mode: RoutingMode): void;
14
9
  getLocation(): string;
15
- add(pattern: RegExp | RouteHandler, handler?: RouteHandler): Router;
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>;
16
14
  /**
17
15
  *
18
16
  * @param location
@@ -20,7 +18,12 @@ declare class Router {
20
18
  */
21
19
  handleChange(location?: string): boolean;
22
20
  listen(mode?: RoutingMode): void;
23
- navigate(path?: string): boolean;
21
+ unlisten(): void;
22
+ navigate(path?: string, opts?: {
23
+ replace?: boolean;
24
+ }): boolean;
25
+ replace(path?: string): boolean;
24
26
  }
25
27
  export declare const router: Router;
28
+ export declare function createRouter(): Router;
26
29
  export {};
package/dist/router.js CHANGED
@@ -1,115 +1,263 @@
1
- class Router {
2
- mode = 'hash';
3
- routes = [];
4
- root = '/';
5
- baseLocation = null;
1
+ export class Router {
2
+ #mode = 'hash';
3
+ #routes = [];
4
+ #root = '/';
5
+ #rootCompare = '';
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'];
10
15
  return staticFileExtensions.some(ext => url.endsWith(ext));
11
16
  });
12
17
  }
13
- cleanPathString(path) {
18
+ #cleanPathString(path) {
14
19
  path = path.replace(/\/$/, '').replace(/^\//, '');
15
20
  return path = path.replace(/#{2,}/g, '#');
16
21
  }
17
- clearQuery(url) {
22
+ #clearQuery(url) {
18
23
  const [path, query] = url.split('?');
19
24
  if (!query)
20
25
  return path;
21
26
  const [_, hash] = query.split('#');
22
27
  return hash ? `${path}#${hash}` : path;
23
28
  }
24
- isStaticFile(url) {
25
- return (this.staticFilters || []).some(filter => filter(url));
29
+ #isStaticFile(url) {
30
+ return this.staticFilters.some(filter => filter(url));
26
31
  }
27
32
  resetRoot(root) {
28
- this.root = '/' + this.cleanPathString(root) + '/';
33
+ const cleaned = this.#cleanPathString(root);
34
+ this.#root = '/' + cleaned + '/';
35
+ this.#rootCompare = cleaned ? cleaned + '/' : '';
36
+ }
37
+ setMode(mode) {
38
+ this.#mode = mode;
29
39
  }
30
40
  getLocation() {
31
- if (this.mode === 'history') {
32
- let fragment = this.cleanPathString(decodeURI(window.location.pathname + window.location.search));
33
- fragment = this.clearQuery(fragment);
34
- return this.root !== '/' ? fragment.replace(this.root, '') : fragment;
41
+ if (!this.#isBrowser())
42
+ return '';
43
+ if (this.#mode === 'history') {
44
+ let fragment = decodeURI(window.location.pathname + window.location.search);
45
+ fragment = this.#clearQuery(fragment);
46
+ // strip leading slash for comparison convenience
47
+ fragment = fragment.replace(/^\//, '');
48
+ if (this.#root !== '/' && this.#rootCompare && fragment.startsWith(this.#rootCompare)) {
49
+ fragment = fragment.slice(this.#rootCompare.length);
50
+ }
51
+ fragment = this.#cleanPathString(fragment);
52
+ return fragment;
35
53
  }
36
54
  else {
37
55
  const match = window.location.href.match(/#(.*)$/);
38
- return match ? this.clearQuery(match[1]) : '';
56
+ return match ? this.#clearQuery(match[1]) : '';
39
57
  }
40
58
  }
41
59
  add(pattern, handler) {
60
+ let paramNames;
42
61
  if (typeof pattern === 'function') {
43
62
  handler = pattern;
44
63
  pattern = /^.*$/; // Match any path
45
64
  }
46
- this.routes.push({ pattern, handler: handler });
65
+ else if (typeof pattern === 'string') {
66
+ const compiled = this.#compilePattern(pattern);
67
+ pattern = compiled.regex;
68
+ paramNames = compiled.paramNames;
69
+ }
70
+ this.#routes.push({ pattern: pattern, handler: handler, paramNames });
71
+ return this;
72
+ }
73
+ onNotFound(handler) {
74
+ this.#notFoundHandler = handler;
47
75
  return this;
48
76
  }
77
+ setDecodeParams(decode) {
78
+ this.#decodeParams = decode;
79
+ return this;
80
+ }
81
+ getQueryParams(search) {
82
+ if (!this.#isBrowser() && !search)
83
+ return {};
84
+ const qs = typeof search === 'string' ? search : window.location.search || '';
85
+ const usp = new URLSearchParams(qs);
86
+ const obj = {};
87
+ usp.forEach((v, k) => { obj[k] = v; });
88
+ return obj;
89
+ }
90
+ #isBrowser() {
91
+ return typeof window !== 'undefined' && typeof document !== 'undefined';
92
+ }
93
+ #compilePattern(pattern) {
94
+ // normalize leading slash to align with cleanPathString behavior
95
+ const normalized = pattern.replace(/^\//, '');
96
+ const paramNames = [];
97
+ // convert /users/:id -> ^users/([^/]+)$
98
+ const reStr = normalized
99
+ .replace(/([.*+?^${}()|[\]\\])/g, '\\$1') // escape regex specials
100
+ .replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, (_m, p1) => {
101
+ paramNames.push(p1);
102
+ return '([^/]+)';
103
+ });
104
+ return { regex: new RegExp(`^${reStr}$`), paramNames };
105
+ }
49
106
  /**
50
107
  *
51
108
  * @param location
52
109
  * @return true if it was intercepted or false if not handled
53
110
  */
54
111
  handleChange(location) {
55
- const path = location || this.getLocation();
56
- if (this.isStaticFile(path))
112
+ const path = (location ?? this.getLocation()) || '';
113
+ if (this.#isStaticFile(path))
57
114
  return false; // Bypass routing for static files
58
- for (const route of this.routes) {
59
- const match = path.match(route.pattern);
115
+ for (const route of this.#routes) {
116
+ const match = route.pattern ? path.match(route.pattern) : null;
60
117
  if (match) {
61
118
  match.shift(); // Remove the full match element
62
- const queryParams = Object.fromEntries(new URLSearchParams(window.location.search));
63
- route.handler({ queryParams }, match);
119
+ const queryParams = this.getQueryParams();
120
+ const captures = this.#decodeParams ? match.map(v => safeDecode(v)) : match;
121
+ let params;
122
+ if (route.paramNames && route.paramNames.length) {
123
+ params = {};
124
+ route.paramNames.forEach((n, i) => params[n] = captures[i]);
125
+ }
126
+ route.handler.call({ queryParams, params }, ...captures);
64
127
  return true;
65
128
  }
66
129
  }
67
- console.warn(`No routing found for ${path}`);
130
+ if (this.#notFoundHandler) {
131
+ this.#notFoundHandler(path);
132
+ return true;
133
+ }
134
+ if (path)
135
+ console.warn(`No routing found for ${path}`);
68
136
  return false;
69
137
  }
70
138
  listen(mode = 'hash') {
139
+ if (!this.#isBrowser())
140
+ return;
141
+ // avoid duplicate listeners
142
+ if (this.#isListening)
143
+ this.unlisten();
71
144
  const self = this;
72
- this.mode = mode;
73
- switch (mode) {
74
- case "hash":
75
- window.addEventListener('hashchange', () => handler());
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))
145
+ this.#mode = mode;
146
+ const handler = (path) => {
147
+ const p = path || location.href.split('#')[0];
148
+ if (self.#isStaticFile(p))
98
149
  return;
99
150
  const currentLocation = self.getLocation();
100
- if (self.baseLocation !== currentLocation) {
101
- self.baseLocation = currentLocation;
151
+ if (self.#baseLocation !== currentLocation) {
152
+ self.#baseLocation = currentLocation;
102
153
  self.handleChange(currentLocation);
103
154
  }
155
+ };
156
+ const handleInternalNavigation = (event) => {
157
+ // modified clicks or non-left clicks
158
+ const me = event;
159
+ if (event.type === 'click') {
160
+ if (me.button !== 0)
161
+ return; // left-click only
162
+ if (me.metaKey || me.ctrlKey || me.shiftKey)
163
+ return;
164
+ }
165
+ const target = event.target;
166
+ if (!target)
167
+ return;
168
+ const anchor = target.closest('a');
169
+ if (!anchor)
170
+ return;
171
+ if (event.type === 'keydown') {
172
+ const ke = event;
173
+ if (ke.key !== 'Enter')
174
+ return;
175
+ }
176
+ const href = anchor.getAttribute('href') || '';
177
+ if (!href)
178
+ return;
179
+ const url = new URL(href, window.location.href);
180
+ // ignore external origins or different hostnames
181
+ if (url.origin !== window.location.origin)
182
+ return;
183
+ // ignore target=_blank, download, or rel=noreferrer
184
+ const t = anchor.getAttribute('target');
185
+ if (t && t.toLowerCase() === '_blank')
186
+ return;
187
+ if (anchor.hasAttribute('download'))
188
+ return;
189
+ const rel = anchor.getAttribute('rel');
190
+ if (rel && /\bnoreferrer\b/i.test(rel))
191
+ return;
192
+ const pathWithQuery = url.pathname + (url.search || '');
193
+ if (self.#isStaticFile(pathWithQuery) || self.#isStaticFile(url.pathname))
194
+ return;
195
+ event.preventDefault();
196
+ history.pushState({ path: pathWithQuery }, '', pathWithQuery);
197
+ handler(pathWithQuery);
198
+ };
199
+ switch (mode) {
200
+ case 'hash':
201
+ this.#bound.hashchange = () => handler();
202
+ window.addEventListener('hashchange', this.#bound.hashchange);
203
+ break;
204
+ case 'history':
205
+ this.#bound.popstate = (event) => handler(this.getLocation());
206
+ this.#bound.click = handleInternalNavigation;
207
+ this.#bound.keydown = (event) => handleInternalNavigation(event);
208
+ window.addEventListener('popstate', this.#bound.popstate);
209
+ document.addEventListener('click', this.#bound.click);
210
+ document.addEventListener('keydown', this.#bound.keydown);
211
+ break;
104
212
  }
213
+ this.#isListening = true;
105
214
  handler();
106
215
  }
107
- navigate(path = '') {
108
- if (this.mode === 'history')
109
- history.pushState(null, null, this.root + this.cleanPathString(path));
216
+ unlisten() {
217
+ if (!this.#isBrowser() || !this.#isListening)
218
+ return;
219
+ switch (this.#mode) {
220
+ case 'hash':
221
+ if (this.#bound.hashchange)
222
+ window.removeEventListener('hashchange', this.#bound.hashchange);
223
+ break;
224
+ case 'history':
225
+ if (this.#bound.popstate)
226
+ window.removeEventListener('popstate', this.#bound.popstate);
227
+ if (this.#bound.click)
228
+ document.removeEventListener('click', this.#bound.click);
229
+ if (this.#bound.keydown)
230
+ document.removeEventListener('keydown', this.#bound.keydown);
231
+ break;
232
+ }
233
+ this.#bound = {};
234
+ this.#isListening = false;
235
+ }
236
+ navigate(path = '', opts) {
237
+ if (!this.#isBrowser())
238
+ return false;
239
+ if (this.#mode === 'history') {
240
+ const url = this.#root + this.#cleanPathString(path);
241
+ if (opts?.replace)
242
+ history.replaceState(null, '', url);
243
+ else
244
+ history.pushState(null, '', url);
245
+ }
110
246
  else
111
- window.location.hash = this.cleanPathString(path);
247
+ window.location.hash = this.#cleanPathString(path);
112
248
  return this.handleChange();
113
249
  }
250
+ replace(path = '') {
251
+ return this.navigate(path, { replace: true });
252
+ }
114
253
  }
115
254
  export const router = new Router();
255
+ export function createRouter() { return new Router(); }
256
+ function safeDecode(v) {
257
+ try {
258
+ return decodeURIComponent(v);
259
+ }
260
+ catch {
261
+ return v;
262
+ }
263
+ }
@@ -1,34 +1,32 @@
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
+ onExit?: (context?: any) => void | Promise<void>;
9
10
  };
10
11
  export type ChangeAuthority = (state: ApplicationState) => Promise<boolean>;
11
12
  export declare class StateManager {
13
+ #private;
12
14
  private mode;
13
- private allStates;
14
- private appState;
15
- private previousState;
16
- private stateContext;
17
15
  static dispatcher: import("@dharmax/pubsub").PubSub;
18
- private changeAuthorities;
19
- constructor(mode?: RoutingMode, autostart?: boolean);
16
+ constructor(mode?: RoutingMode, autostart?: boolean, routerInstance?: RouterType);
20
17
  start(): void;
18
+ stop(): void;
21
19
  onChange(handler: (event: PubSubEvent, data: any) => void): IPubSubHandle;
22
20
  registerChangeAuthority(authorityCallback: (targetState: ApplicationState) => Promise<boolean>): void;
23
21
  getState(): ApplicationState;
24
- get previous(): ApplicationState;
25
- get context(): ApplicationState;
22
+ get previous(): ApplicationState | null;
23
+ get context(): any;
26
24
  /**
27
25
  * set current page state
28
26
  * @param state can be either just a state or a state and context (which can be sub-state, or anything else)
29
27
  */
30
28
  set state(state: ApplicationStateName | [ApplicationStateName, ...any]);
31
- /** attempts to restore state from current url. Currently, works only in hash mode */
29
+ /** attempts to restore state from current url. */
32
30
  restoreState(defaultState: ApplicationStateName): void;
33
31
  /**
34
32
  *
@@ -45,4 +43,9 @@ export declare class StateManager {
45
43
  */
46
44
  addState(name: string, pageName?: string, route?: RegExp | string, mode?: string | string[]): void;
47
45
  registerStateByState(state: ApplicationState): void;
46
+ onBeforeChange(handler: (target: ApplicationState, context?: any) => boolean | Promise<boolean>): void;
47
+ onAfterChange(handler: (state: ApplicationState, context?: any, previous?: ApplicationState | null) => void | Promise<void>): void;
48
+ onExit(stateName: ApplicationStateName, handler: (context?: any) => void | Promise<void>): void;
49
+ onNotFound(handler: (path: string) => void): void;
48
50
  }
51
+ export declare function createStateManager(mode?: RoutingMode, autostart?: boolean, routerInstance?: RouterType): StateManager;