@devera_se/bedrockjs 0.1.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 +519 -0
- package/package.json +65 -0
- package/src/component.d.ts +36 -0
- package/src/component.js +357 -0
- package/src/html.d.ts +12 -0
- package/src/html.js +148 -0
- package/src/index.d.ts +12 -0
- package/src/index.js +50 -0
- package/src/reactive.d.ts +20 -0
- package/src/reactive.js +277 -0
- package/src/render.d.ts +5 -0
- package/src/render.js +326 -0
- package/src/router.d.ts +52 -0
- package/src/router.js +356 -0
package/src/router.js
ADDED
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Router implementation with async data loading
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { Component, autoRegister } from './component.js';
|
|
6
|
+
import { html } from './html.js';
|
|
7
|
+
import { render } from './render.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Router class for managing application routes
|
|
11
|
+
*/
|
|
12
|
+
export class Router {
|
|
13
|
+
#routes = [];
|
|
14
|
+
#outlet = null;
|
|
15
|
+
#currentRoute = null;
|
|
16
|
+
#currentComponent = null;
|
|
17
|
+
#useHash = false;
|
|
18
|
+
#base = '';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Create a new router
|
|
22
|
+
* @param {Object} options - Router options
|
|
23
|
+
* @param {Array} options.routes - Route definitions
|
|
24
|
+
* @param {boolean} options.hash - Use hash-based routing
|
|
25
|
+
* @param {string} options.base - Base path for routes
|
|
26
|
+
*/
|
|
27
|
+
constructor(options = {}) {
|
|
28
|
+
this.#routes = options.routes || [];
|
|
29
|
+
this.#useHash = options.hash || false;
|
|
30
|
+
this.#base = options.base || '';
|
|
31
|
+
|
|
32
|
+
// Register router globally for outlet/link components
|
|
33
|
+
Router.instance = this;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Start the router
|
|
38
|
+
*/
|
|
39
|
+
start() {
|
|
40
|
+
// Listen for navigation events
|
|
41
|
+
window.addEventListener('popstate', this.#handleNavigation);
|
|
42
|
+
if (this.#useHash) {
|
|
43
|
+
window.addEventListener('hashchange', this.#handleNavigation);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Find existing outlet in the DOM
|
|
47
|
+
const existingOutlet = document.querySelector('router-outlet');
|
|
48
|
+
if (existingOutlet) {
|
|
49
|
+
this.setOutlet(existingOutlet);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Handle initial route
|
|
53
|
+
this.#handleNavigation();
|
|
54
|
+
|
|
55
|
+
return this;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Stop the router
|
|
60
|
+
*/
|
|
61
|
+
stop() {
|
|
62
|
+
window.removeEventListener('popstate', this.#handleNavigation);
|
|
63
|
+
if (this.#useHash) {
|
|
64
|
+
window.removeEventListener('hashchange', this.#handleNavigation);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Set the router outlet element
|
|
70
|
+
*/
|
|
71
|
+
setOutlet(outlet) {
|
|
72
|
+
this.#outlet = outlet;
|
|
73
|
+
// Trigger navigation to render the current route
|
|
74
|
+
this.#handleNavigation();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Get current path
|
|
79
|
+
*/
|
|
80
|
+
get currentPath() {
|
|
81
|
+
if (this.#useHash) {
|
|
82
|
+
return window.location.hash.slice(1) || '/';
|
|
83
|
+
}
|
|
84
|
+
let path = window.location.pathname;
|
|
85
|
+
// Strip base path
|
|
86
|
+
if (this.#base && path.startsWith(this.#base)) {
|
|
87
|
+
path = path.slice(this.#base.length);
|
|
88
|
+
}
|
|
89
|
+
// Handle index.html and trailing slashes
|
|
90
|
+
path = path.replace(/\/index\.html$/, '/').replace(/\/$/, '') || '/';
|
|
91
|
+
return path;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Navigate to a path
|
|
96
|
+
* @param {string} path - Path to navigate to
|
|
97
|
+
* @param {Object} options - Navigation options
|
|
98
|
+
*/
|
|
99
|
+
navigate(path, options = {}) {
|
|
100
|
+
const fullPath = this.#useHash ? `#${path}` : `${this.#base}${path}`;
|
|
101
|
+
|
|
102
|
+
if (options.replace) {
|
|
103
|
+
window.history.replaceState(null, '', fullPath);
|
|
104
|
+
} else {
|
|
105
|
+
window.history.pushState(null, '', fullPath);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
this.#handleNavigation();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Handle navigation event
|
|
113
|
+
*/
|
|
114
|
+
#handleNavigation = async () => {
|
|
115
|
+
const path = this.currentPath;
|
|
116
|
+
const matched = this.#matchRoute(path);
|
|
117
|
+
|
|
118
|
+
if (!matched) {
|
|
119
|
+
console.warn(`No route matched for path: ${path}`);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const { route, params } = matched;
|
|
124
|
+
this.#currentRoute = { ...route, params };
|
|
125
|
+
|
|
126
|
+
await this.#renderRoute(route, params);
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Match a path to a route
|
|
131
|
+
*/
|
|
132
|
+
#matchRoute(path) {
|
|
133
|
+
for (const route of this.#routes) {
|
|
134
|
+
const params = this.#matchPath(route.path, path);
|
|
135
|
+
if (params !== null) {
|
|
136
|
+
return { route, params };
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Match a route path pattern against a URL path
|
|
144
|
+
*/
|
|
145
|
+
#matchPath(pattern, path) {
|
|
146
|
+
// Convert pattern to regex
|
|
147
|
+
const paramNames = [];
|
|
148
|
+
const regexPattern = pattern
|
|
149
|
+
.replace(/\//g, '\\/')
|
|
150
|
+
.replace(/:([^/]+)/g, (_, name) => {
|
|
151
|
+
paramNames.push(name);
|
|
152
|
+
return '([^/]+)';
|
|
153
|
+
})
|
|
154
|
+
.replace(/\*/g, '.*');
|
|
155
|
+
|
|
156
|
+
const regex = new RegExp(`^${regexPattern}$`);
|
|
157
|
+
const match = path.match(regex);
|
|
158
|
+
|
|
159
|
+
if (!match) return null;
|
|
160
|
+
|
|
161
|
+
// Extract params
|
|
162
|
+
const params = {};
|
|
163
|
+
paramNames.forEach((name, index) => {
|
|
164
|
+
params[name] = decodeURIComponent(match[index + 1]);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
return params;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Render a route
|
|
172
|
+
*/
|
|
173
|
+
async #renderRoute(route, params) {
|
|
174
|
+
if (!this.#outlet) return;
|
|
175
|
+
|
|
176
|
+
// Create route data object
|
|
177
|
+
const routeData = {
|
|
178
|
+
loading: true,
|
|
179
|
+
data: null,
|
|
180
|
+
error: null,
|
|
181
|
+
params
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
// Create or reuse component
|
|
185
|
+
let component = this.#currentComponent;
|
|
186
|
+
|
|
187
|
+
if (!component || component.tagName.toLowerCase() !== route.component) {
|
|
188
|
+
// Create new component
|
|
189
|
+
component = document.createElement(route.component);
|
|
190
|
+
this.#currentComponent = component;
|
|
191
|
+
|
|
192
|
+
// Set initial loading state BEFORE adding to DOM
|
|
193
|
+
// This ensures routeData is available in connectedCallback
|
|
194
|
+
component.routeData = { ...routeData };
|
|
195
|
+
|
|
196
|
+
// Clear outlet and add component
|
|
197
|
+
this.#outlet.innerHTML = '';
|
|
198
|
+
this.#outlet.appendChild(component);
|
|
199
|
+
} else {
|
|
200
|
+
// Reusing component, set loading state
|
|
201
|
+
component.routeData = { ...routeData };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Run loader if present
|
|
205
|
+
if (route.loader) {
|
|
206
|
+
try {
|
|
207
|
+
const data = await route.loader(params);
|
|
208
|
+
routeData.loading = false;
|
|
209
|
+
routeData.data = data;
|
|
210
|
+
} catch (error) {
|
|
211
|
+
routeData.loading = false;
|
|
212
|
+
routeData.error = error;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Update component with loaded data
|
|
216
|
+
component.routeData = { ...routeData };
|
|
217
|
+
} else {
|
|
218
|
+
// No loader, just set not loading
|
|
219
|
+
routeData.loading = false;
|
|
220
|
+
component.routeData = { ...routeData };
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Add a route dynamically
|
|
226
|
+
*/
|
|
227
|
+
addRoute(route) {
|
|
228
|
+
this.#routes.push(route);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Remove a route
|
|
233
|
+
*/
|
|
234
|
+
removeRoute(path) {
|
|
235
|
+
this.#routes = this.#routes.filter(r => r.path !== path);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Get all routes
|
|
240
|
+
*/
|
|
241
|
+
get routes() {
|
|
242
|
+
return [...this.#routes];
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Check if using hash-based routing
|
|
247
|
+
*/
|
|
248
|
+
get useHash() {
|
|
249
|
+
return this.#useHash;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Global router instance
|
|
254
|
+
Router.instance = null;
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Router outlet component - renders the matched route component
|
|
258
|
+
*/
|
|
259
|
+
export class RouterOutlet extends Component {
|
|
260
|
+
static tag = 'router-outlet';
|
|
261
|
+
|
|
262
|
+
connectedCallback() {
|
|
263
|
+
super.connectedCallback();
|
|
264
|
+
|
|
265
|
+
if (Router.instance) {
|
|
266
|
+
Router.instance.setOutlet(this);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
render() {
|
|
271
|
+
// Content is managed by the router directly
|
|
272
|
+
return null;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Register router outlet
|
|
277
|
+
autoRegister(RouterOutlet);
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Router link component - navigation links
|
|
281
|
+
*/
|
|
282
|
+
export class RouterLink extends Component {
|
|
283
|
+
static tag = 'router-link';
|
|
284
|
+
static shadow = true;
|
|
285
|
+
static properties = {
|
|
286
|
+
to: { type: String },
|
|
287
|
+
replace: { type: Boolean, default: false }
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
#handleClick = (e) => {
|
|
291
|
+
e.preventDefault();
|
|
292
|
+
|
|
293
|
+
if (Router.instance && this.to) {
|
|
294
|
+
Router.instance.navigate(this.to, { replace: this.replace });
|
|
295
|
+
}
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
get href() {
|
|
299
|
+
if (!this.to) return '#';
|
|
300
|
+
if (Router.instance && Router.instance.useHash) {
|
|
301
|
+
return `#${this.to}`;
|
|
302
|
+
}
|
|
303
|
+
return this.to;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
render() {
|
|
307
|
+
return html`
|
|
308
|
+
<style>
|
|
309
|
+
:host {
|
|
310
|
+
display: block;
|
|
311
|
+
}
|
|
312
|
+
a {
|
|
313
|
+
color: inherit;
|
|
314
|
+
text-decoration: inherit;
|
|
315
|
+
display: block;
|
|
316
|
+
cursor: pointer;
|
|
317
|
+
}
|
|
318
|
+
</style>
|
|
319
|
+
<a href="${this.href}" on-click=${this.#handleClick}>
|
|
320
|
+
<slot></slot>
|
|
321
|
+
</a>
|
|
322
|
+
`;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Register router link
|
|
327
|
+
autoRegister(RouterLink);
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Helper to create a router and start it
|
|
331
|
+
*/
|
|
332
|
+
export function createRouter(options) {
|
|
333
|
+
const router = new Router(options);
|
|
334
|
+
return router.start();
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Navigate programmatically
|
|
339
|
+
*/
|
|
340
|
+
export function navigate(path, options) {
|
|
341
|
+
if (Router.instance) {
|
|
342
|
+
Router.instance.navigate(path, options);
|
|
343
|
+
} else {
|
|
344
|
+
console.warn('No router instance found');
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Get current route params
|
|
350
|
+
*/
|
|
351
|
+
export function getParams() {
|
|
352
|
+
if (Router.instance && Router.instance.currentRoute) {
|
|
353
|
+
return Router.instance.currentRoute.params;
|
|
354
|
+
}
|
|
355
|
+
return {};
|
|
356
|
+
}
|