@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/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
+ }