aberdeen 1.17.1 → 2.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.
Files changed (139) hide show
  1. package/dist/src/aberdeen.d.ts +928 -944
  2. package/dist/src/aberdeen.js +1693 -1705
  3. package/dist/src/aberdeen.js.map +3 -3
  4. package/dist/src/index.d.ts +12 -0
  5. package/dist/src/index.js +11 -0
  6. package/dist/src/index.js.map +10 -0
  7. package/dist/src/prediction.d.ts +7 -4
  8. package/dist/src/prediction.js +18 -23
  9. package/dist/src/prediction.js.map +3 -3
  10. package/dist/src/route.d.ts +114 -101
  11. package/dist/src/route.js +246 -253
  12. package/dist/src/route.js.map +3 -3
  13. package/dist/src/server-dom.d.ts +158 -0
  14. package/dist/src/server-dom.js +458 -0
  15. package/dist/src/server-dom.js.map +10 -0
  16. package/dist/src/server.d.ts +55 -0
  17. package/dist/src/server.js +25 -0
  18. package/dist/src/server.js.map +10 -0
  19. package/dist/src/transitions.js +7 -4
  20. package/dist/src/transitions.js.map +3 -3
  21. package/dist/tests/environment.d.ts +24 -0
  22. package/dist/tests/{fakedom.js → environment.js} +239 -288
  23. package/dist/tests/environment.js.map +11 -0
  24. package/dist/tests/helpers.d.ts +15 -3
  25. package/dist/tests/helpers.js +277 -306
  26. package/dist/tests/helpers.js.map +6 -5
  27. package/dist-docs/Tutorial/index.html +30 -30
  28. package/dist-docs/aberdeen/Aberdeen/index.html +350 -0
  29. package/dist-docs/aberdeen/InitOptions/index.html +4 -0
  30. package/dist-docs/aberdeen/KeyToString/index.html +2 -0
  31. package/dist-docs/aberdeen/PromiseProxy/index.html +6 -6
  32. package/dist-docs/aberdeen/SortKeyType/index.html +2 -0
  33. package/dist-docs/aberdeen/TargetType/index.html +2 -0
  34. package/dist-docs/aberdeen/ValueRef/index.html +2 -0
  35. package/dist-docs/aberdeen/WindowLike/index.html +5 -0
  36. package/dist-docs/aberdeen/{clone → createA}/index.html +10 -5
  37. package/dist-docs/aberdeen/index.html +13 -54
  38. package/dist-docs/assets/aberdeen/aberdeen.d.ts +928 -944
  39. package/dist-docs/assets/aberdeen/aberdeen.js +1693 -1705
  40. package/dist-docs/assets/aberdeen/aberdeen.js.map +3 -3
  41. package/dist-docs/assets/aberdeen/index.d.ts +12 -0
  42. package/dist-docs/assets/aberdeen/index.js +11 -0
  43. package/dist-docs/assets/aberdeen/index.js.map +10 -0
  44. package/dist-docs/assets/aberdeen/prediction.d.ts +7 -4
  45. package/dist-docs/assets/aberdeen/prediction.js +18 -23
  46. package/dist-docs/assets/aberdeen/prediction.js.map +3 -3
  47. package/dist-docs/assets/aberdeen/route.d.ts +114 -101
  48. package/dist-docs/assets/aberdeen/route.js +246 -253
  49. package/dist-docs/assets/aberdeen/route.js.map +3 -3
  50. package/dist-docs/assets/aberdeen/server-dom.d.ts +158 -0
  51. package/dist-docs/assets/aberdeen/server-dom.js +458 -0
  52. package/dist-docs/assets/aberdeen/server-dom.js.map +10 -0
  53. package/dist-docs/assets/aberdeen/server.d.ts +55 -0
  54. package/dist-docs/assets/aberdeen/server.js +25 -0
  55. package/dist-docs/assets/aberdeen/server.js.map +10 -0
  56. package/dist-docs/assets/aberdeen/transitions.js +7 -4
  57. package/dist-docs/assets/aberdeen/transitions.js.map +3 -3
  58. package/dist-docs/assets/navigation.js +1 -1
  59. package/dist-docs/assets/search.js +1 -1
  60. package/dist-docs/dispatcher/Dispatcher/index.html +5 -6
  61. package/dist-docs/dispatcher/MATCH_FAILED/index.html +2 -2
  62. package/dist-docs/dispatcher/MATCH_REST/index.html +2 -2
  63. package/dist-docs/dispatcher/index.html +2 -2
  64. package/dist-docs/hierarchy.html +1 -1
  65. package/dist-docs/index-1/index.html +2 -0
  66. package/dist-docs/index.html +2 -2
  67. package/dist-docs/media/CHANGELOG.md +10 -1
  68. package/dist-docs/modules.html +1 -1
  69. package/dist-docs/prediction/applyCanon/index.html +4 -3
  70. package/dist-docs/prediction/applyPrediction/index.html +4 -3
  71. package/dist-docs/prediction/index.html +2 -2
  72. package/dist-docs/route/RouteApi/index.html +60 -0
  73. package/dist-docs/route/{Route → RouteState}/index.html +9 -9
  74. package/dist-docs/route/createRoute/index.html +5 -0
  75. package/dist-docs/route/index.html +6 -15
  76. package/dist-docs/server/RenderToStringOptions/index.html +6 -0
  77. package/dist-docs/server/RenderToStringResult/index.html +6 -0
  78. package/dist-docs/server/index.html +4 -0
  79. package/dist-docs/server/renderToString/index.html +6 -0
  80. package/dist-docs/sitemap.xml +52 -156
  81. package/dist-docs/transitions/grow/index.html +2 -2
  82. package/dist-docs/transitions/index.html +2 -2
  83. package/dist-docs/transitions/shrink/index.html +2 -2
  84. package/package.json +17 -7
  85. package/skill/SKILL.md +28 -28
  86. package/skill/aberdeen.md +471 -3448
  87. package/skill/dispatcher.md +6 -16
  88. package/skill/prediction.md +17 -5
  89. package/skill/route.md +155 -137
  90. package/skill/transitions.md +3 -3
  91. package/src/aberdeen.ts +1224 -741
  92. package/src/index.ts +13 -0
  93. package/src/prediction.ts +25 -24
  94. package/src/route.ts +419 -393
  95. package/src/server-dom.ts +508 -0
  96. package/src/server.ts +74 -0
  97. package/src/transitions.ts +9 -3
  98. package/dist/tests/fakedom.d.ts +0 -9
  99. package/dist/tests/fakedom.js.map +0 -10
  100. package/dist-docs/aberdeen/A/index.html +0 -115
  101. package/dist-docs/aberdeen/CUSTOM_DUMP/index.html +0 -7
  102. package/dist-docs/aberdeen/NO_COPY/index.html +0 -2
  103. package/dist-docs/aberdeen/OPAQUE/index.html +0 -12
  104. package/dist-docs/aberdeen/clean/index.html +0 -11
  105. package/dist-docs/aberdeen/copy/index.html +0 -22
  106. package/dist-docs/aberdeen/count/index.html +0 -7
  107. package/dist-docs/aberdeen/cssVars/index.html +0 -11
  108. package/dist-docs/aberdeen/darkMode/index.html +0 -9
  109. package/dist-docs/aberdeen/default/index.html +0 -341
  110. package/dist-docs/aberdeen/derive/index.html +0 -18
  111. package/dist-docs/aberdeen/disableCreateDestroy/index.html +0 -6
  112. package/dist-docs/aberdeen/dump/index.html +0 -11
  113. package/dist-docs/aberdeen/insertCss/index.html +0 -30
  114. package/dist-docs/aberdeen/insertGlobalCss/index.html +0 -19
  115. package/dist-docs/aberdeen/invertString/index.html +0 -13
  116. package/dist-docs/aberdeen/isEmpty/index.html +0 -12
  117. package/dist-docs/aberdeen/map/index.html +0 -26
  118. package/dist-docs/aberdeen/merge/index.html +0 -11
  119. package/dist-docs/aberdeen/mount/index.html +0 -17
  120. package/dist-docs/aberdeen/multiMap/index.html +0 -28
  121. package/dist-docs/aberdeen/onEach/index.html +0 -19
  122. package/dist-docs/aberdeen/partition/index.html +0 -36
  123. package/dist-docs/aberdeen/peek/index.html +0 -40
  124. package/dist-docs/aberdeen/proxy/index.html +0 -35
  125. package/dist-docs/aberdeen/ref/index.html +0 -13
  126. package/dist-docs/aberdeen/runQueue/index.html +0 -13
  127. package/dist-docs/aberdeen/setErrorHandler/index.html +0 -16
  128. package/dist-docs/aberdeen/setSpacingCssVars/index.html +0 -8
  129. package/dist-docs/aberdeen/unmountAll/index.html +0 -5
  130. package/dist-docs/aberdeen/unproxy/index.html +0 -11
  131. package/dist-docs/route/back/index.html +0 -4
  132. package/dist-docs/route/current/index.html +0 -2
  133. package/dist-docs/route/go/index.html +0 -14
  134. package/dist-docs/route/interceptLinks/index.html +0 -8
  135. package/dist-docs/route/matchCurrent/index.html +0 -10
  136. package/dist-docs/route/persistScroll/index.html +0 -6
  137. package/dist-docs/route/push/index.html +0 -6
  138. package/dist-docs/route/setLog/index.html +0 -3
  139. package/dist-docs/route/up/index.html +0 -5
package/src/route.ts CHANGED
@@ -1,11 +1,9 @@
1
- import A, {leakScope} from "./aberdeen.js";
2
-
3
1
  type NavType = "load" | "back" | "forward" | "go" | "push";
4
2
 
5
3
  /**
6
4
  * The class for the global `route` object.
7
5
  */
8
- export interface Route {
6
+ export interface RouteState {
9
7
  /** The current path of the URL as a string. For instance `"/"` or `"/users/123/feed"`. Paths are normalized to always start with a `/` and never end with a `/` (unless it's the root path). */
10
8
  path: string;
11
9
  /** An convenience array containing path segments, mapping to `path`. For instance `[]` (for `"/"`) or `['users', '123', 'feed']` (for `"/users/123/feed"`). */
@@ -28,446 +26,474 @@ export interface Route {
28
26
  nav: NavType;
29
27
  }
30
28
 
31
- let log: (...args: any) => void = () => {};
29
+ type RouteTarget = string | (string|number)[] | Partial<Omit<Omit<RouteState,"p">,"search"> & {
30
+ /** An convenience array containing path segments, mapping to `path`. For instance `[]` (for `"/"`) or `['users', 123, 'feed']` (for `"/users/123/feed"`). Values may be integers but will be converted to strings.*/
31
+ p: (string|number)[],
32
+ /** The query string interpreted as search parameters. So `"a=x&b=y"` becomes `{a: "x", b: "y", c: 42}`. Values may be integers but will be converted to strings. */
33
+ search: Record<string,string|number>,
34
+ }>;
32
35
 
33
36
  /**
34
- * Configure logging on route changes.
35
- * @param value `true` to enable logging to console, `false` to disable logging, or a custom logging function. Defaults to `false`.
37
+ * The router API returned by {@link createRoute}, bound to a specific Aberdeen instance's window.
36
38
  */
37
- export function setLog(value: boolean | ((...args: any[]) => void)) {
38
- if (value === true) {
39
- log = console.log.bind(console, 'aberdeen router');
40
- } else if (value === false) {
41
- log = () => {};
42
- } else {
43
- log = value;
44
- }
45
- }
46
-
47
- declare const ABERDEEN_FAKE_WINDOW: Window | undefined;
48
- const windowE = typeof ABERDEEN_FAKE_WINDOW !== 'undefined'? ABERDEEN_FAKE_WINDOW : window;
49
- const locationE = windowE.location;
50
- const historyE = windowE.history;
51
-
52
- function getRouteFromBrowser(): Route {
53
- return toCanonRoute({
54
- path: locationE.pathname,
55
- hash: locationE.hash,
56
- search: Object.fromEntries(new URLSearchParams(locationE.search)),
57
- state: historyE.state?.state || {},
58
- }, "load", (historyE.state?.stack?.length || 0) + 1);
39
+ export interface RouteApi {
40
+ /**
41
+ * The global {@link RouteState} object reflecting the current URL and browser history state. Changes you make to this affect the current browser history item (modifying the URL if needed).
42
+ */
43
+ current: RouteState;
44
+ /**
45
+ * Configure logging on route changes.
46
+ * @param value `true` to enable logging to console, `false` to disable logging, or a custom logging function. Defaults to `false`.
47
+ */
48
+ setLog(value: boolean | ((...args: any[]) => void)): void;
49
+ /**
50
+ * Navigate to a new URL by pushing a new history entry.
51
+ *
52
+ * Note that this happens synchronously, immediately updating `route` and processing any reactive updates based on that.
53
+ *
54
+ * @param target A subset of the {@link RouteState} properties to navigate to. If neither `p` nor `path` is given, the current path is used. For other properties, an empty/default value is assumed if not given. For convenience:
55
+ * - You may pass a string instead of an object, which is interpreted as the `path`.
56
+ * - You may pass an array instead of an object, which is interpreted as the `p` array.
57
+ * - If you pass `p`, it may contain numbers, which will be converted to strings.
58
+ * - If you pass `search`, its values may be numbers, which will be converted to strings.
59
+ *
60
+ * Examples:
61
+ * ```js
62
+ * // Navigate to /users/123
63
+ * route.go("/users/123");
64
+ *
65
+ * // Navigate to /users/123?tab=feed#top
66
+ * route.go({p: ["users", 123], search: {tab: "feed"}, hash: "top"});
67
+ * ```
68
+ */
69
+ go(target: RouteTarget, nav?: NavType): void;
70
+ /**
71
+ * Returns `true` if the current route matches `target`.
72
+ *
73
+ * Path must match exactly. Any search params specified in `target` must be present
74
+ * in the current URL, but extra params in the current URL are allowed.
75
+ *
76
+ * Reactive: only reevaluates when the path changes to/from the target path, and
77
+ * when target k/v search pairs are (un)set.
78
+ *
79
+ * Primary usage: 'active' status for menu items.
80
+ *
81
+ * @example
82
+ * ```js
83
+ * // This example assumes interceptLinks() has been called
84
+ * A('a.my-button text=Users href=/users .is-active=', route.matchCurrent('/users'));
85
+ *
86
+ * // Alternatively a route object can be given
87
+ * route.matchCurrent({path: '/users', search: {tab: 'profile'}});
88
+ * ```
89
+ */
90
+ matchCurrent(target: RouteTarget): boolean;
91
+ /**
92
+ * Modify the current route by merging `target` into it (using {@link aberdeen!Aberdeen.merge | A.merge}), pushing a new history entry.
93
+ *
94
+ * This is useful for things like opening modals or side panels, where you want a browser back action to return to the previous state.
95
+ *
96
+ * @param target Same as for {@link go}, but merged into the current route instead deleting all state.
97
+ * @param nav The navigation type to use. Defaults to `undefined`, meaning the navigation type is unchanged from the current route,
98
+ * preventing unwanted page transition animations.
99
+ */
100
+ push(target: RouteTarget, nav?: NavType): void;
101
+ /**
102
+ * Try to go back in history to the first entry that matches the given target. If none is found, the given state will replace the current page. This is useful for "cancel" or "close" actions that should return to the previous page if possible, but create a new page if not (for instance when arriving at the current page through a direct link).
103
+ *
104
+ * Consider using {@link up} to go up in the path hierarchy.
105
+ *
106
+ * @param target The target route to go back to. May be a subset of {@link RouteState}, or a string (for `path`), or an array of strings (for `p`).
107
+ */
108
+ back(target?: RouteTarget): void;
109
+ /**
110
+ * Navigate up in the path hierarchy, by going back to the first history entry
111
+ * that has a shorter path than the current one. If there's none, we just shorten
112
+ * the current path.
113
+ *
114
+ * Note that going back in browser history happens asynchronously, so `route` will not be updated immediately.
115
+ */
116
+ up(stripCount?: number): void;
117
+ /** @internal */
118
+ reset(): void;
119
+ /**
120
+ * Restore and store the vertical and horizontal scroll position for
121
+ * the parent element to the page state.
122
+ *
123
+ * @param {string} name - A unique (within this page) name for this
124
+ * scrollable element. Defaults to 'main'.
125
+ *
126
+ * The scroll position will be persisted in `route.aux.scroll.<name>`.
127
+ */
128
+ persistScroll(name?: string): void;
129
+ /**
130
+ * Intercept clicks and Enter key presses on links (`<a>` tags) and use Aberdeen routing
131
+ * instead of browser navigation for local paths (paths without a protocol or host).
132
+ *
133
+ * This allows you to use regular HTML anchor tags for navigation without needing to
134
+ * manually attach click handlers to each link.
135
+ *
136
+ * @example
137
+ * ```js
138
+ * // In your root component:
139
+ * route.interceptLinks();
140
+ *
141
+ * // Now you can use regular anchor tags:
142
+ * A('a text=About href=/corporate/about');
143
+ * ```
144
+ */
145
+ interceptLinks(): void;
59
146
  }
60
147
 
61
148
  /**
62
- * Deep compare `a` and `b`. If `partial` is true, objects contained in `b` may be a subset
63
- * of their counterparts in `a` and still be considered equal.
64
- */
65
- function equal(a: any, b: any, partial: boolean): boolean {
66
- if (a===b) return true;
67
- if (typeof a !== "object" || !a || typeof b !== "object" || !b) return false; // otherwise they would have been equal
68
- if (a.constructor !== b.constructor) return false;
69
- if (b instanceof Array) {
70
- if (a.length !== b.length) return false;
71
- for(let i = 0; i < b.length; i++) {
72
- if (!equal(a[i], b[i], partial)) return false;
73
- }
74
- } else {
75
- for(const k of Object.keys(b)) {
76
- if (!equal(a[k], b[k], partial)) return false;
149
+ * Initialise routing for an Aberdeen instance and return the router API, bound to that
150
+ * instance's window. In the browser, use the default instance: `init(A)`. For server-side
151
+ * rendering, pass the instance provided by `aberdeen/server`'s render callback.
152
+ *
153
+ * @param A An Aberdeen instance (the default export of `aberdeen`, or one from `init`).
154
+ */
155
+ export function createRoute(A: any): RouteApi {
156
+
157
+ const window = A.window;
158
+ const location = window.location;
159
+ const history = window.history;
160
+
161
+ let log: (...args: any) => void = () => {};
162
+
163
+ function setLog(value: boolean | ((...args: any[]) => void)) {
164
+ if (value === true) {
165
+ log = console.log.bind(console, 'aberdeen router');
166
+ } else if (value === false) {
167
+ log = () => {};
168
+ } else {
169
+ log = value;
77
170
  }
78
- if (!partial) {
79
- for(const k of Object.keys(a)) {
80
- if (!b.hasOwnProperty(k)) return false;
171
+ }
172
+
173
+ function getRouteFromBrowser(): RouteState {
174
+ return toCanonRoute({
175
+ path: location.pathname,
176
+ hash: location.hash,
177
+ search: Object.fromEntries(new URLSearchParams(location.search)),
178
+ state: history.state?.state || {},
179
+ }, "load", (history.state?.stack?.length || 0) + 1);
180
+ }
181
+
182
+ /**
183
+ * Deep compare `a` and `b`. If `partial` is true, objects contained in `b` may be a subset
184
+ * of their counterparts in `a` and still be considered equal.
185
+ */
186
+ function equal(a: any, b: any, partial: boolean): boolean {
187
+ if (a===b) return true;
188
+ if (typeof a !== "object" || !a || typeof b !== "object" || !b) return false; // otherwise they would have been equal
189
+ if (a.constructor !== b.constructor) return false;
190
+ if (b instanceof Array) {
191
+ if (a.length !== b.length) return false;
192
+ for(let i = 0; i < b.length; i++) {
193
+ if (!equal(a[i], b[i], partial)) return false;
194
+ }
195
+ } else {
196
+ for(const k of Object.keys(b)) {
197
+ if (!equal(a[k], b[k], partial)) return false;
198
+ }
199
+ if (!partial) {
200
+ for(const k of Object.keys(a)) {
201
+ if (!b.hasOwnProperty(k)) return false;
202
+ }
81
203
  }
82
204
  }
205
+ return true;
83
206
  }
84
- return true;
85
- }
86
-
87
- function getUrl(target: Route) {
88
- const search = new URLSearchParams(target.search).toString();
89
- return (search ? `${target.path}?${search}` : target.path) + target.hash;
90
- }
91
207
 
92
- function toCanonRoute(target: Partial<Route>, nav: NavType, depth: number): Route {
93
- let path = target.path || (target.p || []).join("/") || "/";
94
- path = (""+path).replace(/\/+$/, "");
95
- if (!path.startsWith("/")) path = `/${path}`;
96
-
97
- return {
98
- path,
99
- hash: target.hash && target.hash !=="#" ? (target.hash.startsWith("#") ? target.hash : "#" + target.hash) : "",
100
- p: path.length > 1 ? path.slice(1).replace(/\/+$/, "").split("/") : [],
101
- nav,
102
- search: typeof target.search === 'object' && target.search ? A.clone(target.search) : {},
103
- state: typeof target.state === 'object' && target.state ? A.clone(target.state) : {},
104
- depth,
105
- };
106
- }
208
+ function getUrl(target: RouteState) {
209
+ const search = new URLSearchParams(target.search).toString();
210
+ return (search ? `${target.path}?${search}` : target.path) + target.hash;
211
+ }
107
212
 
213
+ function toCanonRoute(target: Partial<RouteState>, nav: NavType, depth: number): RouteState {
214
+ let path = target.path || (target.p || []).join("/") || "/";
215
+ path = (""+path).replace(/\/+$/, "");
216
+ if (!path.startsWith("/")) path = `/${path}`;
217
+
218
+ return {
219
+ path,
220
+ hash: target.hash && target.hash !=="#" ? (target.hash.startsWith("#") ? target.hash : "#" + target.hash) : "",
221
+ p: path.length > 1 ? path.slice(1).replace(/\/+$/, "").split("/") : [],
222
+ nav,
223
+ search: typeof target.search === 'object' && target.search ? A.clone(target.search) : {},
224
+ state: typeof target.state === 'object' && target.state ? A.clone(target.state) : {},
225
+ depth,
226
+ };
227
+ }
108
228
 
109
- type RouteTarget = string | (string|number)[] | Partial<Omit<Omit<Route,"p">,"search"> & {
110
- /** An convenience array containing path segments, mapping to `path`. For instance `[]` (for `"/"`) or `['users', 123, 'feed']` (for `"/users/123/feed"`). Values may be integers but will be converted to strings.*/
111
- p: (string|number)[],
112
- /** The query string interpreted as search parameters. So `"a=x&b=y"` becomes `{a: "x", b: "y", c: 42}`. Values may be integers but will be converted to strings. */
113
- search: Record<string,string|number>,
114
- }>;
115
229
 
116
- function targetToPartial(target: RouteTarget, undefinedOnExternal: true): Partial<Route> | undefined;
117
- function targetToPartial(target: RouteTarget): Partial<Route>;
230
+ function targetToPartial(target: RouteTarget, undefinedOnExternal: true): Partial<RouteState> | undefined;
231
+ function targetToPartial(target: RouteTarget): Partial<RouteState>;
118
232
 
119
233
 
120
- function targetToPartial(target: RouteTarget, undefinedOnExternal: boolean=false) {
121
- // Convert shortcut values to objects
122
- if (typeof target === 'string') {
123
- // Parse using URL to handle both absolute and relative paths correctly
124
- const url = new URL(target, locationE.href);
125
- if (url.host !== locationE.host) {
126
- if (undefinedOnExternal) return;
127
- throw new Error(`Unexpected external URL: ${url.host} != ${locationE.host}`);
234
+ function targetToPartial(target: RouteTarget, undefinedOnExternal: boolean=false) {
235
+ // Convert shortcut values to objects
236
+ if (typeof target === 'string') {
237
+ // Parse using URL to handle both absolute and relative paths correctly
238
+ const url = new URL(target, location.href);
239
+ if (url.host !== location.host) {
240
+ if (undefinedOnExternal) return;
241
+ throw new Error(`Unexpected external URL: ${url.host} != ${location.host}`);
242
+ }
243
+ target = {
244
+ path: url.pathname,
245
+ search: Object.fromEntries(url.searchParams),
246
+ hash: url.hash,
247
+ };
248
+ } else if (target instanceof Array) {
249
+ target = {p: target};
128
250
  }
129
- target = {
130
- path: url.pathname,
131
- search: Object.fromEntries(url.searchParams),
132
- hash: url.hash,
133
- };
134
- } else if (target instanceof Array) {
135
- target = {p: target};
136
- }
137
- // Convert numbers in p and search to strings
138
- if (target.p) {
139
- target.p = target.p.map(String);
140
- }
141
- if (target.search) {
142
- for(const key of Object.keys(target.search)) {
143
- target.search[key] = String(target.search[key]);
251
+ // Convert numbers in p and search to strings
252
+ if (target.p) {
253
+ target.p = target.p.map(String);
144
254
  }
255
+ if (target.search) {
256
+ for(const key of Object.keys(target.search)) {
257
+ target.search[key] = String(target.search[key]);
258
+ }
259
+ }
260
+ return target as Partial<RouteState>;
145
261
  }
146
- return target as Partial<Route>;
147
- }
148
262
 
149
263
 
150
- /**
151
- * Navigate to a new URL by pushing a new history entry.
152
- *
153
- * Note that this happens synchronously, immediately updating `route` and processing any reactive updates based on that.
154
- *
155
- * @param target A subset of the {@link Route} properties to navigate to. If neither `p` nor `path` is given, the current path is used. For other properties, an empty/default value is assumed if not given. For convenience:
156
- * - You may pass a string instead of an object, which is interpreted as the `path`.
157
- * - You may pass an array instead of an object, which is interpreted as the `p` array.
158
- * - If you pass `p`, it may contain numbers, which will be converted to strings.
159
- * - If you pass `search`, its values may be numbers, which will be converted to strings.
160
- *
161
- * Examples:
162
- * ```js
163
- * // Navigate to /users/123
164
- * route.go("/users/123");
165
- *
166
- * // Navigate to /users/123?tab=feed#top
167
- * route.go({p: ["users", 123], search: {tab: "feed"}, hash: "top"});
168
- * ```
169
- */
170
- export function go(target: RouteTarget, nav: NavType = "go"): void {
171
- pendingGoOffset = 0;
172
- const stack: string[] = historyE.state?.stack || [];
173
-
174
- prevStack = stack.concat(JSON.stringify(A.unproxy(current)));
175
-
176
- const newRoute: Route = toCanonRoute(targetToPartial(target), nav, prevStack.length + 1);
177
- A.copy(current, newRoute);
178
-
179
- log(nav, newRoute);
180
- historyE.pushState({state: newRoute.state, stack: prevStack}, "", getUrl(newRoute));
181
-
182
- A.runQueue();
183
- }
184
-
185
- /**
186
- * Returns `true` if the current route matches `target`.
187
- *
188
- * Path must match exactly. Any search params specified in `target` must be present
189
- * in the current URL, but extra params in the current URL are allowed.
190
- *
191
- * Reactive: only reevaluates when the path changes to/from the target path, and
192
- * when target k/v search pairs are (un)set.
193
- *
194
- * Primary usage: 'active' status for menu items.
195
- *
196
- * @example
197
- * ```js
198
- * // This example assumes interceptLinks() has been called
199
- * A('a.my-button text=Users href=/users .is-active=', route.matchCurrent('/users'));
200
- *
201
- * // Alternatively a route object can be given
202
- * route.matchCurrent({path: '/users', search: {tab: 'profile'}});
203
- * ```
204
- */
205
- export function matchCurrent(target: RouteTarget): boolean {
206
- const partial = targetToPartial(target, true);
207
- if (!partial) return false; // External link
264
+ function go(target: RouteTarget, nav: NavType = "go"): void {
265
+ pendingGoOffset = 0;
266
+ const stack: string[] = history.state?.stack || [];
208
267
 
209
- if (partial.path != null || partial.p != null) {
210
- let path = partial.path || (partial.p || []).join("/") || "/";
211
- path = (""+path).replace(/\/+$/, "");
212
- if (!path.startsWith("/")) path = `/${path}`;
213
- if (!currentRouteParts[path]) return false;
268
+ prevStack = stack.concat(JSON.stringify(A.unproxy(current)));
269
+
270
+ const newRoute: RouteState = toCanonRoute(targetToPartial(target), nav, prevStack.length + 1);
271
+ A.copy(current, newRoute);
272
+
273
+ log(nav, newRoute);
274
+ history.pushState({state: newRoute.state, stack: prevStack}, "", getUrl(newRoute));
275
+
276
+ A.runQueue();
214
277
  }
215
278
 
216
- if (partial.search) {
217
- for(const [k,v] of Object.entries(partial.search)) {
218
- if (!currentRouteParts[`${k}=${v}`]) return false;
279
+ function matchCurrent(target: RouteTarget): boolean {
280
+ const partial = targetToPartial(target, true);
281
+ if (!partial) return false; // External link
282
+
283
+ if (partial.path != null || partial.p != null) {
284
+ let path = partial.path || (partial.p || []).join("/") || "/";
285
+ path = (""+path).replace(/\/+$/, "");
286
+ if (!path.startsWith("/")) path = `/${path}`;
287
+ if (!currentRouteParts[path]) return false;
219
288
  }
289
+
290
+ if (partial.search) {
291
+ for(const [k,v] of Object.entries(partial.search)) {
292
+ if (!currentRouteParts[`${k}=${v}`]) return false;
293
+ }
294
+ }
295
+ return true;
220
296
  }
221
- return true;
222
- }
223
297
 
224
- /**
225
- * Modify the current route by merging `target` into it (using {@link aberdeen.merge | A.merge}), pushing a new history entry.
226
- *
227
- * This is useful for things like opening modals or side panels, where you want a browser back action to return to the previous state.
228
- *
229
- * @param target Same as for {@link go}, but merged into the current route instead deleting all state.
230
- * @param nav The navigation type to use. Defaults to `undefined`, meaning the navigation type is unchanged from the current route,
231
- * preventing unwanted page transition animations.
232
- */
233
- export function push(target: RouteTarget, nav?: NavType): void {
234
- const c = A.clone(A.unproxy(current));
235
- A.merge(c, targetToPartial(target));
236
- go(c, nav || c.nav);
237
- }
298
+ function push(target: RouteTarget, nav?: NavType): void {
299
+ const c = A.clone(A.unproxy(current));
300
+ A.merge(c, targetToPartial(target));
301
+ go(c, nav || c.nav);
302
+ }
238
303
 
239
- /**
240
- * Try to go back in history to the first entry that matches the given target. If none is found, the given state will replace the current page. This is useful for "cancel" or "close" actions that should return to the previous page if possible, but create a new page if not (for instance when arriving at the current page through a direct link).
241
- *
242
- * Consider using {@link up} to go up in the path hierarchy.
243
- *
244
- * @param target The target route to go back to. May be a subset of {@link Route}, or a string (for `path`), or an array of strings (for `p`).
245
- */
246
- export function back(target: RouteTarget = {}): void {
247
- const partial = targetToPartial(target);
248
- const stack: string[] = historyE.state?.stack || [];
249
- const effectiveLen = stack.length + pendingGoOffset;
250
- for(let i = effectiveLen - 1; i >= 0; i--) {
251
- const histRoute: Route = JSON.parse(stack[i]);
252
- if (equal(histRoute, partial, true)) {
253
- const pages = i - effectiveLen;
254
- log(`back`, pages, histRoute);
255
- scheduleHistoryGo(pages);
256
- return;
304
+ function back(target: RouteTarget = {}): void {
305
+ const partial = targetToPartial(target);
306
+ const stack: string[] = history.state?.stack || [];
307
+ const effectiveLen = stack.length + pendingGoOffset;
308
+ for(let i = effectiveLen - 1; i >= 0; i--) {
309
+ const histRoute: RouteState = JSON.parse(stack[i]);
310
+ if (equal(histRoute, partial, true)) {
311
+ const pages = i - effectiveLen;
312
+ log(`back`, pages, histRoute);
313
+ scheduleHistoryGo(pages);
314
+ return;
315
+ }
257
316
  }
258
- }
259
317
 
260
- const newRoute = toCanonRoute(partial, "back", effectiveLen + 1);
261
- log(`back not found, replacing`, partial);
262
- A.copy(current, newRoute);
263
- }
318
+ const newRoute = toCanonRoute(partial, "back", effectiveLen + 1);
319
+ log(`back not found, replacing`, partial);
320
+ A.copy(current, newRoute);
321
+ }
264
322
 
265
- /**
266
- * Navigate up in the path hierarchy, by going back to the first history entry
267
- * that has a shorter path than the current one. If there's none, we just shorten
268
- * the current path.
269
- *
270
- * Note that going back in browser history happens asynchronously, so `route` will not be updated immediately.
271
- */
272
- export function up(stripCount: number = 1): void {
273
- const currentP = A.unproxy(current).p;
274
- const stack: string[] = historyE.state?.stack || [];
275
- const effectiveLen = stack.length + pendingGoOffset;
276
- for(let i = effectiveLen - 1; i >= 0; i--) {
277
- const histRoute: Route = JSON.parse(stack[i]);
278
- if (histRoute.p.length < currentP.length && equal(histRoute.p, currentP.slice(0, histRoute.p.length), false)) {
279
- // This route is shorter and matches the start of the current path
280
- log(`up to ${i+1} / ${effectiveLen}`, histRoute);
281
- scheduleHistoryGo(i - effectiveLen);
282
- return;
323
+ function up(stripCount: number = 1): void {
324
+ const currentP = A.unproxy(current).p;
325
+ const stack: string[] = history.state?.stack || [];
326
+ const effectiveLen = stack.length + pendingGoOffset;
327
+ for(let i = effectiveLen - 1; i >= 0; i--) {
328
+ const histRoute: RouteState = JSON.parse(stack[i]);
329
+ if (histRoute.p.length < currentP.length && equal(histRoute.p, currentP.slice(0, histRoute.p.length), false)) {
330
+ // This route is shorter and matches the start of the current path
331
+ log(`up to ${i+1} / ${effectiveLen}`, histRoute);
332
+ scheduleHistoryGo(i - effectiveLen);
333
+ return;
334
+ }
283
335
  }
336
+ // Replace current route with /
337
+ const newRoute = toCanonRoute({p: currentP.slice(0, currentP.length - stripCount)}, "back", effectiveLen + 1);
338
+ log(`up not found, replacing`, newRoute);
339
+ A.copy(current, newRoute);
284
340
  }
285
- // Replace current route with /
286
- const newRoute = toCanonRoute({p: currentP.slice(0, currentP.length - stripCount)}, "back", effectiveLen + 1);
287
- log(`up not found, replacing`, newRoute);
288
- A.copy(current, newRoute);
289
- }
290
341
 
291
342
 
292
- let prevStack: string[];
343
+ let prevStack: string[];
293
344
 
294
- // Track pending historyE.go() offset. Multiple back()/up() calls before the event loop
295
- // processes them are batched into a single historyE.go() via queueMicrotask.
296
- let pendingGoOffset = 0;
345
+ // Track pending historyE().go() offset. Multiple back()/up() calls before the event loop
346
+ // processes them are batched into a single historyE().go() via queueMicrotask.
347
+ let pendingGoOffset = 0;
297
348
 
298
- function scheduleHistoryGo(delta: number) {
299
- pendingGoOffset += delta;
300
- setTimeout(() => {
301
- if (pendingGoOffset) {
302
- const offset = pendingGoOffset;
303
- pendingGoOffset = 0;
304
- historyE.go(offset);
305
- }
306
- }, 0);
307
- }
349
+ function scheduleHistoryGo(delta: number) {
350
+ pendingGoOffset += delta;
351
+ (A.window.setTimeout ?? setTimeout)(() => {
352
+ if (pendingGoOffset) {
353
+ const offset = pendingGoOffset;
354
+ pendingGoOffset = 0;
355
+ history.go(offset);
356
+ }
357
+ }, 0);
358
+ }
308
359
 
309
- /**
310
- * The global {@link Route} object reflecting the current URL and browser history state. Changes you make to this affect the current browser history item (modifying the URL if needed).
311
- */
312
- export const current: Route = A.proxy({}) as Route;
360
+ const current: RouteState = A.proxy({}) as RouteState;
361
+
362
+ // Proxied object with keys for the current path and "k=v" search pairs.
363
+ // Used by matchCurrent to subscribe only to relevant route changes.
364
+ const currentRouteParts = A.proxy({} as Record<string,true>);
365
+
366
+ /**
367
+ * Reset the router to its initial state, based on the current browser state. Intended for testing purposes only.
368
+ * @internal
369
+ * */
370
+ function reset() {
371
+ prevStack = history.state?.stack || [];
372
+ const initRoute = getRouteFromBrowser();
373
+ log('initial', initRoute);
374
+ A.copy(A.unproxy(current), initRoute);
375
+ }
313
376
 
314
- // Proxied object with keys for the current path and "k=v" search pairs.
315
- // Used by matchCurrent to subscribe only to relevant route changes.
316
- const currentRouteParts = A.proxy({} as Record<string,true>);
377
+ reset();
317
378
 
318
- /**
319
- * Reset the router to its initial state, based on the current browser state. Intended for testing purposes only.
320
- * @internal
321
- * */
322
- export function reset() {
323
- prevStack = historyE.state?.stack || [];
324
- const initRoute = getRouteFromBrowser();
325
- log('initial', initRoute);
326
- A.copy(A.unproxy(current), initRoute);
327
- }
328
- reset();
329
-
330
- // Handle browser history back and forward
331
- windowE.addEventListener("popstate", function(event: PopStateEvent) {
332
- pendingGoOffset = 0;
333
- const newRoute = getRouteFromBrowser();
334
-
335
- // If the stack length changes, and at least the top-most shared entry is the same,
336
- // we'll interpret this as a "back" or "forward" navigation.
337
- const stack: string[] = historyE.state?.stack || [];
338
- if (stack.length !== prevStack.length) {
339
- const maxIndex = Math.min(prevStack.length, stack.length) - 1;
340
- if (maxIndex < 0 || stack[maxIndex] === prevStack[maxIndex]) {
341
- newRoute.nav = stack.length < prevStack.length ? "back" : "forward";
379
+ // Handle browser history back and forward
380
+ window.addEventListener("popstate", function(event: PopStateEvent) {
381
+ pendingGoOffset = 0;
382
+ const newRoute = getRouteFromBrowser();
383
+
384
+ // If the stack length changes, and at least the top-most shared entry is the same,
385
+ // we'll interpret this as a "back" or "forward" navigation.
386
+ const stack: string[] = history.state?.stack || [];
387
+ if (stack.length !== prevStack.length) {
388
+ const maxIndex = Math.min(prevStack.length, stack.length) - 1;
389
+ if (maxIndex < 0 || stack[maxIndex] === prevStack[maxIndex]) {
390
+ newRoute.nav = stack.length < prevStack.length ? "back" : "forward";
391
+ }
342
392
  }
343
- }
344
- // else nav will be "load"
345
-
346
- prevStack = stack;
347
- log('popstate', newRoute);
348
- A.copy(current, newRoute);
349
-
350
- A.runQueue();
351
- });
352
-
353
- // Make sure these observers are never cleaned up, not even by `unmountAll`.
354
- leakScope(() => {
355
- // Sync `p` to `path`. We need to do this in a separate, higher-priority observer,
356
- // so that setting `route.p` will not be immediately overruled by the pre-existing `route.path`.
357
- A(() => {
358
- current.path = "/" + Array.from(current.p).join("/");
359
- });
360
-
361
- // Do a replaceState based on changes to A.proxy
362
- A(() => {
363
- // First normalize `route`
364
- const stack = historyE.state?.stack || [];
365
- const newRoute = toCanonRoute(current, A.unproxy(current).nav, stack.length + 1);
393
+ // else nav will be "load"
394
+
395
+ prevStack = stack;
396
+ log('popstate', newRoute);
366
397
  A.copy(current, newRoute);
367
398
 
368
- // Then replace the current browser state if something actually changed
369
- const state = {state: newRoute.state, stack};
370
- const url = getUrl(newRoute);
371
- if (url !== locationE.pathname + locationE.search + locationE.hash || !equal(historyE.state, state, false)) {
372
- log('replaceState', newRoute, state, url);
373
- historyE.replaceState(state, "", url);
374
- }
399
+ A.runQueue();
375
400
  });
376
401
 
377
- // Keep currentRouteParts in sync with the current path and search params.
378
- A(() => {
379
- const n = {} as Record<string,true>;
380
- n[current.path] = true;
381
- for(const [k,v] of Object.entries(current.search)) {
382
- n[`${k}=${v}`] = true;
383
- }
384
- A.copy(currentRouteParts, n);
402
+ // Make sure these observers are never cleaned up, not even by `unmountAll`.
403
+ A.leakScope(() => {
404
+ // Sync `p` to `path`. We need to do this in a separate, higher-priority observer,
405
+ // so that setting `route.p` will not be immediately overruled by the pre-existing `route.path`.
406
+ A(() => {
407
+ current.path = "/" + Array.from(current.p).join("/");
408
+ });
409
+
410
+ // Do a replaceState based on changes to A.proxy
411
+ A(() => {
412
+ // First normalize `route`
413
+ const stack = history.state?.stack || [];
414
+ const newRoute = toCanonRoute(current, A.unproxy(current).nav, stack.length + 1);
415
+ A.copy(current, newRoute);
416
+
417
+ // Then replace the current browser state if something actually changed
418
+ const state = {state: newRoute.state, stack};
419
+ const url = getUrl(newRoute);
420
+ if (url !== location.pathname + location.search + location.hash || !equal(history.state, state, false)) {
421
+ log('replaceState', newRoute, state, url);
422
+ history.replaceState(state, "", url);
423
+ }
424
+ });
425
+
426
+ // Keep currentRouteParts in sync with the current path and search params.
427
+ A(() => {
428
+ const n = {} as Record<string,true>;
429
+ n[current.path] = true;
430
+ for(const [k,v] of Object.entries(current.search)) {
431
+ n[`${k}=${v}`] = true;
432
+ }
433
+ A.copy(currentRouteParts, n);
434
+ });
385
435
  });
386
- });
387
-
388
-
389
- /**
390
- * Restore and store the vertical and horizontal scroll position for
391
- * the parent element to the page state.
392
- *
393
- * @param {string} name - A unique (within this page) name for this
394
- * scrollable element. Defaults to 'main'.
395
- *
396
- * The scroll position will be persisted in `route.aux.scroll.<name>`.
397
- */
398
- export function persistScroll(name = "main") {
399
- const el = A()!;
400
- A('scroll=', onScroll);
401
-
402
- const restore = A.unproxy(current).state.scroll?.[name];
403
- if (restore) {
404
- log("restoring scroll", name, restore);
405
- Object.assign(el, restore);
406
- }
407
-
408
- function onScroll() {
409
- (current.state.scroll ||= {})[name] = {
410
- scrollTop: el.scrollTop,
411
- scrollLeft: el.scrollLeft,
412
- };
413
- }
414
- }
415
436
 
416
- /**
417
- * Intercept clicks and Enter key presses on links (`<a>` tags) and use Aberdeen routing
418
- * instead of browser navigation for local paths (paths without a protocol or host).
419
- *
420
- * This allows you to use regular HTML anchor tags for navigation without needing to
421
- * manually attach click handlers to each link.
422
- *
423
- * @example
424
- * ```js
425
- * // In your root component:
426
- * route.interceptLinks();
427
- *
428
- * // Now you can use regular anchor tags:
429
- * A('a text=About href=/corporate/about');
430
- * ```
431
- */
432
- export function interceptLinks() {
433
- A({
434
- click: handleEvent,
435
- keydown: handleKeyEvent,
436
- });
437
-
438
- function handleKeyEvent(e: KeyboardEvent) {
439
- if (e.key === "Enter") {
440
- handleEvent(e);
441
- }
442
- }
443
-
444
- function handleEvent(e: Event) {
445
- // Find the closest <a> tag
446
- let target = e.target as HTMLElement | null;
447
- while (target && target.tagName?.toUpperCase() !== "A") {
448
- target = target.parentElement;
449
- }
450
-
451
- if (!target) return;
437
+ function persistScroll(name = "main") {
438
+ const el = A()!;
439
+ A('scroll=', onScroll);
452
440
 
453
- const anchor = target as HTMLAnchorElement;
454
- const href = anchor.getAttribute("href");
455
-
456
- if (!href) return;
457
-
458
- // Skip hash-only links
459
- if (href.startsWith("#")) return;
460
-
461
- // Skip if it has a protocol or is protocol-relative (// or contains : before any / ? #)
462
- if (href.startsWith("//") || /^[^/?#]+:/.test(href)) return;
441
+ const restore = A.unproxy(current).state.scroll?.[name];
442
+ if (restore) {
443
+ log("restoring scroll", name, restore);
444
+ Object.assign(el, restore);
445
+ }
463
446
 
464
- // Skip if the link has target or download attribute
465
- if (anchor.getAttribute("target") || anchor.getAttribute("download")) return;
447
+ function onScroll() {
448
+ (current.state.scroll ||= {})[name] = {
449
+ scrollTop: el.scrollTop,
450
+ scrollLeft: el.scrollLeft,
451
+ };
452
+ }
453
+ }
454
+
455
+ function interceptLinks() {
456
+ A({
457
+ click: handleEvent,
458
+ keydown: handleKeyEvent,
459
+ });
466
460
 
467
- // Skip if modifier keys are pressed (Ctrl/Cmd click to open in new tab)
468
- if (typeof MouseEvent !== 'undefined' && e instanceof MouseEvent && (e.ctrlKey || e.metaKey || e.shiftKey)) return;
461
+ function handleKeyEvent(e: KeyboardEvent) {
462
+ if (e.key === "Enter") {
463
+ handleEvent(e);
464
+ }
465
+ }
469
466
 
470
- e.preventDefault();
471
- go(href);
467
+ function handleEvent(e: Event) {
468
+ // Find the closest <a> tag
469
+ let target = e.target as HTMLElement | null;
470
+ while (target && target.tagName?.toUpperCase() !== "A") {
471
+ target = target.parentElement;
472
+ }
473
+
474
+ if (!target) return;
475
+
476
+ const anchor = target as HTMLAnchorElement;
477
+ const href = anchor.getAttribute("href");
478
+
479
+ if (!href) return;
480
+
481
+ // Skip hash-only links
482
+ if (href.startsWith("#")) return;
483
+
484
+ // Skip if it has a protocol or is protocol-relative (// or contains : before any / ? #)
485
+ if (href.startsWith("//") || /^[^/?#]+:/.test(href)) return;
486
+
487
+ // Skip if the link has target or download attribute
488
+ if (anchor.getAttribute("target") || anchor.getAttribute("download")) return;
489
+
490
+ // Skip if modifier keys are pressed (Ctrl/Cmd click to open in new tab)
491
+ if (typeof MouseEvent !== 'undefined' && e instanceof MouseEvent && (e.ctrlKey || e.metaKey || e.shiftKey)) return;
492
+
493
+ e.preventDefault();
494
+ go(href);
495
+ }
472
496
  }
497
+
498
+ return { current, setLog, go, matchCurrent, push, back, up, reset, persistScroll, interceptLinks } as RouteApi;
473
499
  }