@wavelengthusaf/client-router 0.0.7 → 0.1.0
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 +555 -132
- package/dist/elements/RouteElement.d.ts +3 -0
- package/dist/elements/RouteElement.d.ts.map +1 -1
- package/dist/elements/RouteElement.js +20 -0
- package/dist/elements/RouteElement.js.map +1 -1
- package/dist/elements/RouterElement.d.ts +8 -0
- package/dist/elements/RouterElement.d.ts.map +1 -1
- package/dist/elements/RouterElement.js +176 -20
- package/dist/elements/RouterElement.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/machine/createMachine.d.ts +3 -0
- package/dist/machine/createMachine.d.ts.map +1 -0
- package/dist/machine/createMachine.js +52 -0
- package/dist/machine/createMachine.js.map +1 -0
- package/dist/machine/index.d.ts +3 -0
- package/dist/machine/index.d.ts.map +1 -0
- package/dist/machine/index.js +2 -0
- package/dist/machine/index.js.map +1 -0
- package/dist/machine/machineTypes.d.ts +8 -0
- package/dist/machine/machineTypes.d.ts.map +1 -0
- package/dist/machine/machineTypes.js +2 -0
- package/dist/machine/machineTypes.js.map +1 -0
- package/dist/models/RouteComponent.d.ts +1 -1
- package/dist/models/RouteComponent.d.ts.map +1 -1
- package/dist/models/RouteDefinition.d.ts +2 -0
- package/dist/models/RouteDefinition.d.ts.map +1 -1
- package/dist/router/Router.d.ts +24 -1
- package/dist/router/Router.d.ts.map +1 -1
- package/dist/router/Router.js +574 -47
- package/dist/router/Router.js.map +1 -1
- package/dist/utils/compileRoute.js.map +1 -1
- package/dist/utils/matchRoute.d.ts.map +1 -1
- package/dist/utils/matchRoute.js.map +1 -1
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -7,18 +7,31 @@ A lightweight, native TypeScript web components router for modern web applicatio
|
|
|
7
7
|
- [Getting Started](#getting-started)
|
|
8
8
|
- [Prerequisites](#prerequisites)
|
|
9
9
|
- [Installation](#installation)
|
|
10
|
-
|
|
11
|
-
- [Adding to Your Project](#adding-to-your-project)
|
|
10
|
+
- [Use in Your Application](#use-in-your-application)
|
|
12
11
|
- [Usage](#usage)
|
|
13
12
|
- [HTML Template Approach](#html-template-approach)
|
|
14
13
|
- [TypeScript Approach](#typescript-approach)
|
|
14
|
+
- [Router Lifecycle](#router-lifecycle)
|
|
15
|
+
- [Direct Router Scoping](#direct-router-scoping)
|
|
15
16
|
- [API Reference](#api-reference)
|
|
16
17
|
- [`<wavelength-router>` Attributes](#wavelength-router-attributes)
|
|
17
18
|
- [`<wavelength-route>` Attributes](#wavelength-route-attributes)
|
|
18
19
|
- [RouteContext](#routecontext)
|
|
19
20
|
- [RouteGuard](#routeguard)
|
|
21
|
+
- [Route-Level Error Handling](#route-level-error-handling)
|
|
22
|
+
- [Navigation Lifecycle Events](#navigation-lifecycle-events)
|
|
20
23
|
- [Programmatic Navigation](#programmatic-navigation)
|
|
24
|
+
- [Route Preloading](#route-preloading)
|
|
25
|
+
- [Focus Management](#focus-management)
|
|
26
|
+
- [Shadow DOM Navigation](#shadow-dom-navigation)
|
|
27
|
+
- [Injected Browser Environment](#injected-browser-environment)
|
|
28
|
+
- [Matching Utilities](#matching-utilities)
|
|
21
29
|
- [Best Practices](#best-practices)
|
|
30
|
+
- [Contributing and Local Development](#contributing-and-local-development)
|
|
31
|
+
- [Repository Setup](#repository-setup)
|
|
32
|
+
- [Husky Setup](#husky-setup)
|
|
33
|
+
- [Development Commands](#development-commands)
|
|
34
|
+
- [CI/CD Expectations](#cicd-expectations)
|
|
22
35
|
|
|
23
36
|
---
|
|
24
37
|
|
|
@@ -26,43 +39,20 @@ A lightweight, native TypeScript web components router for modern web applicatio
|
|
|
26
39
|
|
|
27
40
|
### Prerequisites
|
|
28
41
|
|
|
29
|
-
- Node.js
|
|
42
|
+
- Node.js 24+ and npm
|
|
30
43
|
- A modern browser with native web components support
|
|
31
44
|
|
|
32
45
|
### Installation
|
|
33
46
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
```bash
|
|
37
|
-
git clone https://github.com/your-org/wavelength-router.git
|
|
38
|
-
cd wavelength-router
|
|
39
|
-
npm install
|
|
40
|
-
```
|
|
41
|
-
|
|
42
|
-
### Husky Setup
|
|
43
|
-
|
|
44
|
-
This project uses Husky to run linting and tests before each commit. If you're setting up the project for the first time or if Husky hooks aren't working, initialize it with:
|
|
45
|
-
|
|
46
|
-
```bash
|
|
47
|
-
npm run prepare
|
|
48
|
-
```
|
|
49
|
-
|
|
50
|
-
Or manually initialize:
|
|
47
|
+
Install the package in your application:
|
|
51
48
|
|
|
52
49
|
```bash
|
|
53
|
-
|
|
50
|
+
npm install @wavelengthusaf/client-router
|
|
54
51
|
```
|
|
55
52
|
|
|
56
|
-
This ensures that before every commit, Husky will:
|
|
57
|
-
|
|
58
|
-
1. Run unit tests via `npm run test`
|
|
59
|
-
2. Run ESLint and Prettier via `npm run lint`
|
|
60
|
-
|
|
61
|
-
If either fails, the commit will be blocked.
|
|
62
|
-
|
|
63
53
|
---
|
|
64
54
|
|
|
65
|
-
##
|
|
55
|
+
## Use in Your Application
|
|
66
56
|
|
|
67
57
|
### Option 1: npm Package (Published)
|
|
68
58
|
|
|
@@ -79,20 +69,6 @@ Then import the elements in your entry point:
|
|
|
79
69
|
import '@wavelengthusaf/client-router';
|
|
80
70
|
```
|
|
81
71
|
|
|
82
|
-
### Option 2: Local Development
|
|
83
|
-
|
|
84
|
-
For local development or testing changes:
|
|
85
|
-
|
|
86
|
-
```bash
|
|
87
|
-
npm run build
|
|
88
|
-
```
|
|
89
|
-
|
|
90
|
-
This compiles TypeScript to `dist/`. Then in your consuming project:
|
|
91
|
-
|
|
92
|
-
```typescript
|
|
93
|
-
import '@wavelengthusaf/client-router';
|
|
94
|
-
```
|
|
95
|
-
|
|
96
72
|
---
|
|
97
73
|
|
|
98
74
|
## Usage
|
|
@@ -113,15 +89,14 @@ Use the custom elements directly in your HTML. This is ideal for simpler applica
|
|
|
113
89
|
<title>My App</title>
|
|
114
90
|
</head>
|
|
115
91
|
<body>
|
|
116
|
-
<!--
|
|
117
|
-
<nav>
|
|
118
|
-
<a href="/">Home</a>
|
|
119
|
-
<a href="/about">About</a>
|
|
120
|
-
<a href="/users/123">User Profile</a>
|
|
121
|
-
</nav>
|
|
122
|
-
|
|
123
|
-
<!-- Router Container - renders matched route component here -->
|
|
92
|
+
<!-- Router Container - owns navigation and renders matched pages -->
|
|
124
93
|
<wavelength-router>
|
|
94
|
+
<nav aria-label="Main navigation">
|
|
95
|
+
<a href="/">Home</a>
|
|
96
|
+
<a href="/about">About</a>
|
|
97
|
+
<a href="/users/123">User Profile</a>
|
|
98
|
+
</nav>
|
|
99
|
+
|
|
125
100
|
<wavelength-route path="/" component="home-page"></wavelength-route>
|
|
126
101
|
<wavelength-route path="/about" component="about-page"></wavelength-route>
|
|
127
102
|
<wavelength-route
|
|
@@ -177,12 +152,18 @@ Use the custom elements directly in your HTML. This is ideal for simpler applica
|
|
|
177
152
|
</html>
|
|
178
153
|
```
|
|
179
154
|
|
|
155
|
+
`<wavelength-router>` delegates links from its own Light DOM and open Shadow
|
|
156
|
+
DOM descendants. Keep declarative navigation inside the router, outside the
|
|
157
|
+
rendered page outlet. Links outside the router intentionally retain native
|
|
158
|
+
browser navigation; external controls can call `router.navigate(path)`.
|
|
159
|
+
|
|
180
160
|
#### How It Works
|
|
181
161
|
|
|
182
|
-
1. `<wavelength-router>`
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
162
|
+
1. `<wavelength-router>` collects route declarations from its Light DOM and
|
|
163
|
+
open Shadow DOM descendants.
|
|
164
|
+
2. It preserves those declarations and mounts the matching component in a
|
|
165
|
+
separate internal Light DOM outlet.
|
|
166
|
+
3. Routes are matched in declaration order, so place a wildcard route last.
|
|
186
167
|
|
|
187
168
|
---
|
|
188
169
|
|
|
@@ -197,113 +178,126 @@ import { Router } from '@wavelengthusaf/client-router';
|
|
|
197
178
|
import type {
|
|
198
179
|
RouteContext,
|
|
199
180
|
RouteDefinition,
|
|
200
|
-
RouteGuard,
|
|
201
181
|
} from '@wavelengthusaf/client-router';
|
|
202
182
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
this.innerHTML = '<h1>About Us</h1>';
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
class UserPage extends HTMLElement {
|
|
217
|
-
connectedCallback() {
|
|
218
|
-
const userId = this.routeContext?.routeParams?.userId;
|
|
219
|
-
this.innerHTML = `<h1>User Profile</h1><p>User ID: ${userId}</p>`;
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
// Register components
|
|
224
|
-
customElements.define('home-page', HomePage);
|
|
225
|
-
customElements.define('about-page', AboutPage);
|
|
226
|
-
customElements.define('user-page', UserPage);
|
|
183
|
+
const mountElement =
|
|
184
|
+
(tag: string) =>
|
|
185
|
+
(container: HTMLElement, context: RouteContext): void => {
|
|
186
|
+
const page = document.createElement(tag) as HTMLElement & {
|
|
187
|
+
routeContext?: RouteContext;
|
|
188
|
+
};
|
|
189
|
+
page.routeContext = context;
|
|
190
|
+
container.appendChild(page);
|
|
191
|
+
};
|
|
227
192
|
|
|
228
|
-
// Define routes using RouteDefinition objects
|
|
229
193
|
const routes: RouteDefinition[] = [
|
|
230
194
|
{
|
|
231
195
|
path: '/',
|
|
232
196
|
routeComponent: {
|
|
233
|
-
mount
|
|
234
|
-
const element = document.createElement('home-page') as HTMLElement & {
|
|
235
|
-
routeContext?: RouteContext;
|
|
236
|
-
};
|
|
237
|
-
element.routeContext = context;
|
|
238
|
-
container.appendChild(element);
|
|
239
|
-
},
|
|
240
|
-
unmount() {
|
|
241
|
-
// Optional cleanup
|
|
242
|
-
},
|
|
243
|
-
},
|
|
244
|
-
},
|
|
245
|
-
{
|
|
246
|
-
path: '/about',
|
|
247
|
-
routeComponent: {
|
|
248
|
-
mount(container: HTMLElement, context: RouteContext) {
|
|
249
|
-
const element = document.createElement('about-page') as HTMLElement & {
|
|
250
|
-
routeContext?: RouteContext;
|
|
251
|
-
};
|
|
252
|
-
element.routeContext = context;
|
|
253
|
-
container.appendChild(element);
|
|
254
|
-
},
|
|
197
|
+
mount: mountElement('home-page'),
|
|
255
198
|
},
|
|
256
199
|
},
|
|
257
200
|
{
|
|
258
201
|
path: '/users/:userId',
|
|
259
202
|
routeComponent: {
|
|
260
|
-
mount
|
|
261
|
-
const element = document.createElement('user-page') as HTMLElement & {
|
|
262
|
-
routeContext?: RouteContext;
|
|
263
|
-
};
|
|
264
|
-
element.routeContext = context;
|
|
265
|
-
container.appendChild(element);
|
|
266
|
-
},
|
|
203
|
+
mount: mountElement('user-page'),
|
|
267
204
|
},
|
|
268
205
|
},
|
|
269
206
|
];
|
|
270
207
|
|
|
271
|
-
|
|
208
|
+
const outlet = document.querySelector<HTMLElement>('#app')!;
|
|
272
209
|
const router = new Router(routes, {
|
|
273
|
-
outlet
|
|
274
|
-
hashMode: false, //
|
|
210
|
+
outlet,
|
|
211
|
+
hashMode: false, // History API mode; use true for #/path URLs.
|
|
275
212
|
});
|
|
276
213
|
|
|
277
214
|
router.start();
|
|
278
215
|
```
|
|
279
216
|
|
|
217
|
+
Use `Router` directly when the host application owns its outlet, route
|
|
218
|
+
definitions, and lifecycle. It is also useful in tests because browser globals
|
|
219
|
+
can be supplied through the optional third constructor argument.
|
|
220
|
+
|
|
280
221
|
#### RouteDefinition Structure
|
|
281
222
|
|
|
282
223
|
Each route requires:
|
|
283
224
|
|
|
284
225
|
- `path`: The URL path pattern (supports `:param` syntax for dynamic segments)
|
|
285
226
|
- `routeComponent`: An object with a `mount` function (and optional `unmount`)
|
|
227
|
+
- `beforeEach`: Optional route-specific navigation guard
|
|
286
228
|
|
|
287
229
|
```typescript
|
|
288
230
|
interface RouteDefinition {
|
|
289
231
|
path: string; // e.g., '/users/:id' or '/products/:category/:id'
|
|
290
232
|
routeComponent: {
|
|
291
|
-
mount
|
|
233
|
+
mount: (
|
|
234
|
+
container: HTMLElement,
|
|
235
|
+
context: RouteContext,
|
|
236
|
+
) => void | Promise<void>;
|
|
292
237
|
unmount?(): void; // Optional cleanup function
|
|
293
238
|
};
|
|
239
|
+
beforeEach?: RouteGuard;
|
|
294
240
|
}
|
|
295
241
|
```
|
|
296
242
|
|
|
243
|
+
### Router Lifecycle
|
|
244
|
+
|
|
245
|
+
Call `start()` after the outlet exists. Call `destroy()` when the host is
|
|
246
|
+
removed or replaced; it removes navigation listeners and runs the active
|
|
247
|
+
route's optional `unmount()` hook.
|
|
248
|
+
|
|
249
|
+
```typescript
|
|
250
|
+
const router = new Router(routes, { outlet });
|
|
251
|
+
router.start();
|
|
252
|
+
|
|
253
|
+
window.addEventListener('pagehide', () => router.destroy(), { once: true });
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
Each navigation clears the outlet, runs the prior route's `unmount()`, and then
|
|
257
|
+
calls the next route's `mount(outlet, context)`.
|
|
258
|
+
|
|
259
|
+
`mount()` may return a promise. The router waits to mark that route complete
|
|
260
|
+
until the promise resolves. If a newer navigation begins first, the older mount
|
|
261
|
+
is treated as cancelled: it cannot become active or emit a completion event,
|
|
262
|
+
and its optional `unmount()` runs when its promise settles. Route components
|
|
263
|
+
should use their own `AbortController` for work that must be stopped before a
|
|
264
|
+
promise settles.
|
|
265
|
+
|
|
266
|
+
### Direct Router Scoping
|
|
267
|
+
|
|
268
|
+
By default, a direct `Router` listens on its injected `document`. For a nested
|
|
269
|
+
router or a router inside Shadow DOM, `eventTarget` and `navigationRoot` are
|
|
270
|
+
required: without both, multiple document-level routers can handle the same
|
|
271
|
+
composed navigation event. Scope them to the containing host:
|
|
272
|
+
|
|
273
|
+
```typescript
|
|
274
|
+
const host = document.querySelector<HTMLElement>('#account-router')!;
|
|
275
|
+
const outlet = host.querySelector<HTMLElement>('[data-outlet]')!;
|
|
276
|
+
|
|
277
|
+
const router = new Router(routes, {
|
|
278
|
+
outlet,
|
|
279
|
+
eventTarget: host,
|
|
280
|
+
navigationRoot: host,
|
|
281
|
+
});
|
|
282
|
+
router.start();
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
Using the same containing element for `eventTarget` and `navigationRoot`
|
|
286
|
+
prevents an outer router from acting on links owned by a nested router. A
|
|
287
|
+
top-level direct router may omit these options when document-wide navigation is
|
|
288
|
+
intended.
|
|
289
|
+
|
|
297
290
|
---
|
|
298
291
|
|
|
299
292
|
## API Reference
|
|
300
293
|
|
|
301
294
|
### `<wavelength-router>` Attributes
|
|
302
295
|
|
|
303
|
-
| Attribute
|
|
304
|
-
|
|
|
305
|
-
| `hash-mode`
|
|
306
|
-
| `guard`
|
|
296
|
+
| Attribute | Type | Description |
|
|
297
|
+
| ---------------- | --------------------------- | ------------------------------------------------------- |
|
|
298
|
+
| `hash-mode` | flag | Enable hash-based routing (`#/path`) instead of history |
|
|
299
|
+
| `guard` | string | Name of a global guard function on `window` |
|
|
300
|
+
| `focus-strategy` | `none`, `outlet`, `heading` | Focus target after navigation; defaults to `none` |
|
|
307
301
|
|
|
308
302
|
#### Example with Hash Mode
|
|
309
303
|
|
|
@@ -316,12 +310,23 @@ interface RouteDefinition {
|
|
|
316
310
|
|
|
317
311
|
URL becomes: `https://example.com/#/profile`
|
|
318
312
|
|
|
313
|
+
Hash-mode query strings are supported. For `#/profile?tab=security`, the route
|
|
314
|
+
matches `/profile` and `routeContext.query.get('tab')` is `security`.
|
|
315
|
+
External route-hash changes are observed automatically. Hash routes use the
|
|
316
|
+
`#/path` form, so ordinary fragments such as `href="#details"` retain native
|
|
317
|
+
in-page scrolling behavior. Same-origin absolute links ending in `#/path` are
|
|
318
|
+
also normalized as hash routes.
|
|
319
|
+
|
|
319
320
|
### `<wavelength-route>` Attributes
|
|
320
321
|
|
|
321
|
-
| Attribute | Type | Description
|
|
322
|
-
| ----------- | ------ |
|
|
323
|
-
| `path` | string | URL path pattern (supports `:param` syntax)
|
|
324
|
-
| `component` | string | Custom element tag name to render for this route
|
|
322
|
+
| Attribute | Type | Description |
|
|
323
|
+
| ----------- | ------ | -------------------------------------------------- |
|
|
324
|
+
| `path` | string | URL path pattern (supports `:param` syntax) |
|
|
325
|
+
| `component` | string | Custom element tag name to render for this route |
|
|
326
|
+
| `guard` | string | Optional route-specific guard function on `window` |
|
|
327
|
+
|
|
328
|
+
Changing any route attribute on a connected route automatically rebuilds the
|
|
329
|
+
router's definitions.
|
|
325
330
|
|
|
326
331
|
#### Path Patterns
|
|
327
332
|
|
|
@@ -348,12 +353,10 @@ interface RouteContext {
|
|
|
348
353
|
|
|
349
354
|
```typescript
|
|
350
355
|
class MyPage extends HTMLElement {
|
|
351
|
-
|
|
352
|
-
return (this as unknown as { routeContext?: RouteContext }).routeContext;
|
|
353
|
-
}
|
|
356
|
+
public routeContext?: RouteContext;
|
|
354
357
|
|
|
355
|
-
connectedCallback() {
|
|
356
|
-
const ctx =
|
|
358
|
+
public connectedCallback(): void {
|
|
359
|
+
const ctx = this.routeContext;
|
|
357
360
|
if (ctx) {
|
|
358
361
|
console.log('Current path:', ctx.path);
|
|
359
362
|
console.log('User ID:', ctx.routeParams.userId);
|
|
@@ -365,7 +368,9 @@ class MyPage extends HTMLElement {
|
|
|
365
368
|
|
|
366
369
|
### RouteGuard
|
|
367
370
|
|
|
368
|
-
Navigation guards allow you to intercept and redirect navigation. Define a
|
|
371
|
+
Navigation guards allow you to intercept and redirect navigation. Define a
|
|
372
|
+
guard function and reference it via the `guard` attribute on
|
|
373
|
+
`<wavelength-router>` or a specific `<wavelength-route>`.
|
|
369
374
|
|
|
370
375
|
```typescript
|
|
371
376
|
// Define guard on window (required for HTML approach)
|
|
@@ -383,16 +388,53 @@ type NextCallback = (redirect?: string) => void;
|
|
|
383
388
|
next(); // Allow navigation
|
|
384
389
|
}
|
|
385
390
|
};
|
|
391
|
+
|
|
392
|
+
(window as unknown as Record<string, unknown>).permissionGuard = (
|
|
393
|
+
to: RouteContext,
|
|
394
|
+
next: NextCallback,
|
|
395
|
+
) => {
|
|
396
|
+
const user = getCurrentUser();
|
|
397
|
+
|
|
398
|
+
if (!user?.permissions.includes('billing:read')) {
|
|
399
|
+
next('/forbidden');
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
next();
|
|
404
|
+
};
|
|
386
405
|
```
|
|
387
406
|
|
|
388
407
|
```html
|
|
389
408
|
<wavelength-router guard="authGuard">
|
|
390
409
|
<wavelength-route path="/" component="home-page"></wavelength-route>
|
|
391
410
|
<wavelength-route
|
|
392
|
-
path="/
|
|
393
|
-
component="
|
|
411
|
+
path="/billing"
|
|
412
|
+
component="billing-page"
|
|
413
|
+
guard="permissionGuard"
|
|
394
414
|
></wavelength-route>
|
|
395
415
|
<wavelength-route path="/login" component="login-page"></wavelength-route>
|
|
416
|
+
<wavelength-route
|
|
417
|
+
path="/forbidden"
|
|
418
|
+
component="forbidden-page"
|
|
419
|
+
></wavelength-route>
|
|
420
|
+
</wavelength-router>
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
The router-level guard runs before a matched route's guard. If either guard
|
|
424
|
+
redirects, later guards and the matched component mount are skipped for that
|
|
425
|
+
navigation.
|
|
426
|
+
|
|
427
|
+
Use a route-level guard when only one route or route group needs a specific
|
|
428
|
+
permission check:
|
|
429
|
+
|
|
430
|
+
```html
|
|
431
|
+
<wavelength-router>
|
|
432
|
+
<wavelength-route path="/" component="home-page"></wavelength-route>
|
|
433
|
+
<wavelength-route
|
|
434
|
+
path="/reports"
|
|
435
|
+
component="reports-page"
|
|
436
|
+
guard="reportsGuard"
|
|
437
|
+
></wavelength-route>
|
|
396
438
|
</wavelength-router>
|
|
397
439
|
```
|
|
398
440
|
|
|
@@ -401,12 +443,108 @@ type NextCallback = (redirect?: string) => void;
|
|
|
401
443
|
1. Guard receives `RouteContext` with destination path
|
|
402
444
|
2. Guard calls `next()` to allow navigation or `next('/redirect-path')` to redirect
|
|
403
445
|
|
|
446
|
+
Guards may call `next()` asynchronously. If another navigation starts first,
|
|
447
|
+
the older continuation is ignored so it cannot replace the newer route. If a
|
|
448
|
+
route's `guard` attribute changes while connected, `<wavelength-router>`
|
|
449
|
+
rebuilds its route definitions automatically.
|
|
450
|
+
|
|
451
|
+
For a direct `Router`, pass a global guard as `beforeEach`, or attach a
|
|
452
|
+
route-specific guard to an individual route definition:
|
|
453
|
+
|
|
454
|
+
```typescript
|
|
455
|
+
const routes: RouteDefinition[] = [
|
|
456
|
+
{
|
|
457
|
+
path: '/billing',
|
|
458
|
+
routeComponent: { mount: mountElement('billing-page') },
|
|
459
|
+
beforeEach: permissionGuard,
|
|
460
|
+
},
|
|
461
|
+
];
|
|
462
|
+
|
|
463
|
+
const router = new Router(routes, {
|
|
464
|
+
outlet,
|
|
465
|
+
beforeEach: (context, next) => {
|
|
466
|
+
if (context.path.startsWith('/protected') && !getCurrentUser()) {
|
|
467
|
+
next('/login');
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
next();
|
|
472
|
+
},
|
|
473
|
+
});
|
|
474
|
+
```
|
|
475
|
+
|
|
476
|
+
### Route-Level Error Handling
|
|
477
|
+
|
|
478
|
+
Pass `onError` to a direct `Router` to handle route matching, guard, mount, and
|
|
479
|
+
unmount failures in one place. The hook receives the thrown value, its phase,
|
|
480
|
+
and the matched `RouteContext` when one exists. It works in both Light and
|
|
481
|
+
Shadow DOM because it is independent of event retargeting.
|
|
482
|
+
|
|
483
|
+
```typescript
|
|
484
|
+
import type { RouteErrorDetail } from '@wavelengthusaf/client-router';
|
|
485
|
+
|
|
486
|
+
const router = new Router(routes, {
|
|
487
|
+
outlet,
|
|
488
|
+
onError: ({ error, phase, context }: RouteErrorDetail) => {
|
|
489
|
+
reportRouteError(error, { phase, path: context?.path });
|
|
490
|
+
|
|
491
|
+
if (phase === 'mount') {
|
|
492
|
+
outlet.textContent = 'Sorry, this page could not be loaded.';
|
|
493
|
+
}
|
|
494
|
+
},
|
|
495
|
+
});
|
|
496
|
+
```
|
|
497
|
+
|
|
498
|
+
The phases are `match`, `guard`, `mount`, and `unmount`. Supplying `onError`
|
|
499
|
+
handles synchronous guard and mount errors so the router remains started.
|
|
500
|
+
Unmount errors are reported and do not prevent the next route from mounting.
|
|
501
|
+
Without a hook, synchronous guard and mount errors retain their normal thrown
|
|
502
|
+
behavior. `wavelength-route-error` is dispatched in all cases.
|
|
503
|
+
|
|
504
|
+
### Navigation Lifecycle Events
|
|
505
|
+
|
|
506
|
+
The router emits composed, bubbling events for each navigation. Listen on the
|
|
507
|
+
`<wavelength-router>` itself, an ancestor, or the configured `eventTarget` of a
|
|
508
|
+
direct `Router`. They are useful for loading indicators, telemetry, and error
|
|
509
|
+
reporting without coupling page components to router internals.
|
|
510
|
+
|
|
511
|
+
| Event | When it fires | `detail` |
|
|
512
|
+
| --------------------------- | -------------------------------------------------------- | ---------------------------------- |
|
|
513
|
+
| `wavelength-route-start` | A route is matched, before its guard or component mounts | `{ path, context }` |
|
|
514
|
+
| `wavelength-route-complete` | The matched component has mounted successfully | `{ path, context }` |
|
|
515
|
+
| `wavelength-route-error` | Route matching, a guard, mount, or unmount fails | `{ path, context?, error, phase }` |
|
|
516
|
+
|
|
517
|
+
`context` is the normal `RouteContext`. A redirect starts a new navigation for
|
|
518
|
+
its destination and therefore emits completion only for the destination route.
|
|
519
|
+
|
|
520
|
+
```typescript
|
|
521
|
+
import type { RouteLifecycleEventDetail } from '@wavelengthusaf/client-router';
|
|
522
|
+
|
|
523
|
+
document.addEventListener('wavelength-route-start', (event: Event) => {
|
|
524
|
+
const { context } = (event as CustomEvent<RouteLifecycleEventDetail>).detail;
|
|
525
|
+
loadingIndicator.show(context?.path);
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
document.addEventListener('wavelength-route-complete', () => {
|
|
529
|
+
loadingIndicator.hide();
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
document.addEventListener('wavelength-route-error', (event: Event) => {
|
|
533
|
+
const { error, path } = (event as CustomEvent<RouteLifecycleEventDetail>)
|
|
534
|
+
.detail;
|
|
535
|
+
reportNavigationError(path, error);
|
|
536
|
+
});
|
|
537
|
+
```
|
|
538
|
+
|
|
404
539
|
### Programmatic Navigation
|
|
405
540
|
|
|
406
541
|
The router provides navigation methods:
|
|
407
542
|
|
|
408
543
|
```typescript
|
|
409
|
-
|
|
544
|
+
import type { RouterElement } from '@wavelengthusaf/client-router';
|
|
545
|
+
|
|
546
|
+
const routerElement =
|
|
547
|
+
document.querySelector<RouterElement>('wavelength-router')!;
|
|
410
548
|
|
|
411
549
|
// Navigate to a new path (adds to history)
|
|
412
550
|
routerElement.navigate('/about');
|
|
@@ -416,6 +554,235 @@ routerElement.replace('/new-page');
|
|
|
416
554
|
|
|
417
555
|
// Go back in history
|
|
418
556
|
routerElement.back();
|
|
557
|
+
|
|
558
|
+
// Rescan routes after dynamically attaching an open shadow root
|
|
559
|
+
routerElement.refresh();
|
|
560
|
+
```
|
|
561
|
+
|
|
562
|
+
The same methods are available on a `Router` instance. Prefer `replace()` for
|
|
563
|
+
post-login or canonicalization redirects when the previous URL should not
|
|
564
|
+
remain in browser history. Programmatic navigation accepts only valid
|
|
565
|
+
same-origin HTTP(S) paths; external and malformed targets are ignored.
|
|
566
|
+
|
|
567
|
+
### Route Preloading
|
|
568
|
+
|
|
569
|
+
Use the application-controlled `preload` hook to prepare resources for likely
|
|
570
|
+
next pages. The router calls it for eligible same-origin route links on hover
|
|
571
|
+
and keyboard focus, and it receives the matched `RouteContext`. Preloading
|
|
572
|
+
never delays navigation.
|
|
573
|
+
|
|
574
|
+
```typescript
|
|
575
|
+
const router = new Router(routes, {
|
|
576
|
+
outlet,
|
|
577
|
+
preload: async (context) => {
|
|
578
|
+
await pageResourceCache.load(context.path, context.query);
|
|
579
|
+
},
|
|
580
|
+
});
|
|
581
|
+
```
|
|
582
|
+
|
|
583
|
+
`router.preload('/reports?range=month')` starts the same work explicitly.
|
|
584
|
+
`router.getPreloadStatus(path)` returns `idle`, `preloading`, `ready`, or
|
|
585
|
+
`failed`. Pending and successful paths are deduplicated; a failed preload is
|
|
586
|
+
retried by the next explicit call, hover, or focus. A preload failure does not
|
|
587
|
+
affect route navigation or `onError` handling.
|
|
588
|
+
|
|
589
|
+
For declarative routing, configure the property before or after connection:
|
|
590
|
+
|
|
591
|
+
```typescript
|
|
592
|
+
import type { RouterElement } from '@wavelengthusaf/client-router';
|
|
593
|
+
|
|
594
|
+
const routerElement =
|
|
595
|
+
document.querySelector<RouterElement>('wavelength-router')!;
|
|
596
|
+
routerElement.preloader = async (context) => {
|
|
597
|
+
await pageResourceCache.load(context.path, context.query);
|
|
598
|
+
};
|
|
599
|
+
|
|
600
|
+
// Start the same preload work explicitly.
|
|
601
|
+
routerElement.preload('/reports?range=month');
|
|
602
|
+
|
|
603
|
+
// Read the current status: idle, preloading, ready, or failed.
|
|
604
|
+
const status = routerElement.getPreloadStatus('/reports?range=month');
|
|
605
|
+
```
|
|
606
|
+
|
|
607
|
+
The preload machine is intentionally scoped per `Router`. It retains useful
|
|
608
|
+
pending work across ordinary navigations; route components should use their own
|
|
609
|
+
`AbortController` for resource work that must be cancelled.
|
|
610
|
+
|
|
611
|
+
### Focus Management
|
|
612
|
+
|
|
613
|
+
Routing removes the previous page from the outlet. Configure focus management
|
|
614
|
+
when a page change should be announced to keyboard and screen-reader users.
|
|
615
|
+
|
|
616
|
+
```html
|
|
617
|
+
<wavelength-router focus-strategy="heading">
|
|
618
|
+
<wavelength-route path="/" component="home-page"></wavelength-route>
|
|
619
|
+
</wavelength-router>
|
|
620
|
+
```
|
|
621
|
+
|
|
622
|
+
`heading` focuses the first `h1` or level-one ARIA heading in the newly mounted
|
|
623
|
+
page, adding `tabindex="-1"` when needed. `outlet` focuses the render outlet
|
|
624
|
+
itself. If a Light DOM or open Shadow DOM heading is rendered asynchronously,
|
|
625
|
+
the router waits up to five seconds before focusing. Both strategies work
|
|
626
|
+
across Light DOM and open Shadow DOM. For targets that become ready later,
|
|
627
|
+
dispatch `wavelength-focus-ready` as the component-owned focus handoff.
|
|
628
|
+
|
|
629
|
+
An open root attached after navigation cannot be discovered by mutation
|
|
630
|
+
observation alone. When its focus target is ready, dispatch a composed
|
|
631
|
+
`wavelength-focus-ready` event from that target:
|
|
632
|
+
|
|
633
|
+
```typescript
|
|
634
|
+
heading.dispatchEvent(
|
|
635
|
+
new CustomEvent('wavelength-focus-ready', {
|
|
636
|
+
bubbles: true,
|
|
637
|
+
composed: true,
|
|
638
|
+
}),
|
|
639
|
+
);
|
|
640
|
+
```
|
|
641
|
+
|
|
642
|
+
For a closed root, include the target in the event detail; this is an explicit
|
|
643
|
+
component-to-router handoff and does not require exposing the root:
|
|
644
|
+
|
|
645
|
+
```typescript
|
|
646
|
+
heading.dispatchEvent(
|
|
647
|
+
new CustomEvent('wavelength-focus-ready', {
|
|
648
|
+
bubbles: true,
|
|
649
|
+
composed: true,
|
|
650
|
+
detail: { target: heading },
|
|
651
|
+
}),
|
|
652
|
+
);
|
|
653
|
+
```
|
|
654
|
+
|
|
655
|
+
Direct `Router` users can also supply a callback for application-specific
|
|
656
|
+
targets:
|
|
657
|
+
|
|
658
|
+
```typescript
|
|
659
|
+
const router = new Router(routes, {
|
|
660
|
+
outlet,
|
|
661
|
+
focusStrategy: (currentOutlet) =>
|
|
662
|
+
currentOutlet.querySelector<HTMLElement>('[data-route-focus]'),
|
|
663
|
+
});
|
|
664
|
+
```
|
|
665
|
+
|
|
666
|
+
The callback should return a focusable element. Use `focusStrategy: 'none'` to
|
|
667
|
+
retain the default behavior.
|
|
668
|
+
|
|
669
|
+
Closed Shadow DOM is intentionally private. Components that render their focus
|
|
670
|
+
target asynchronously in a closed root should focus that target themselves
|
|
671
|
+
when it is ready.
|
|
672
|
+
|
|
673
|
+
### Shadow DOM Navigation
|
|
674
|
+
|
|
675
|
+
Links inside the router's Light DOM and open Shadow DOM descendants are
|
|
676
|
+
intercepted automatically. Route declarations may also live in open Shadow DOM
|
|
677
|
+
descendants of the router. Each nested router owns navigation from its own DOM
|
|
678
|
+
subtree.
|
|
679
|
+
|
|
680
|
+
Modified clicks, downloads, and links targeting another browsing context keep
|
|
681
|
+
their native browser behavior. Same-document fragment links such as
|
|
682
|
+
`href="#details"` also retain native scrolling behavior. The router handles
|
|
683
|
+
only valid same-origin HTTP(S) destinations.
|
|
684
|
+
|
|
685
|
+
```typescript
|
|
686
|
+
const shell = document.querySelector('app-shell')!;
|
|
687
|
+
const shadowRoot = shell.attachShadow({ mode: 'open' });
|
|
688
|
+
shadowRoot.innerHTML = '<a href="/settings">Settings</a>';
|
|
689
|
+
// The router resolves this link through the event's composed path.
|
|
690
|
+
```
|
|
691
|
+
|
|
692
|
+
If an open root is attached after the router connects, add its route
|
|
693
|
+
declarations and then call `refresh()` so the router can scan and observe it:
|
|
694
|
+
|
|
695
|
+
```typescript
|
|
696
|
+
import type { RouterElement } from '@wavelengthusaf/client-router';
|
|
697
|
+
|
|
698
|
+
const router = document.querySelector<RouterElement>('wavelength-router')!;
|
|
699
|
+
const routeHost = document.querySelector('route-declarations')!;
|
|
700
|
+
const shadowRoot = routeHost.attachShadow({ mode: 'open' });
|
|
701
|
+
shadowRoot.innerHTML =
|
|
702
|
+
'<wavelength-route path="/reports" component="reports-page"></wavelength-route>';
|
|
703
|
+
|
|
704
|
+
router.refresh();
|
|
705
|
+
```
|
|
706
|
+
|
|
707
|
+
Closed Shadow DOM intentionally hides its internal links from event delegation.
|
|
708
|
+
Dispatch a composed `wavelength-navigate` event from the closed-root component
|
|
709
|
+
instead:
|
|
710
|
+
|
|
711
|
+
```typescript
|
|
712
|
+
button.addEventListener('click', () => {
|
|
713
|
+
button.dispatchEvent(
|
|
714
|
+
new CustomEvent('wavelength-navigate', {
|
|
715
|
+
bubbles: true,
|
|
716
|
+
cancelable: true,
|
|
717
|
+
composed: true,
|
|
718
|
+
detail: { path: '/account' },
|
|
719
|
+
}),
|
|
720
|
+
);
|
|
721
|
+
});
|
|
722
|
+
```
|
|
723
|
+
|
|
724
|
+
Set `detail.replace` to `true` to replace the current history entry. Nested
|
|
725
|
+
routers handle navigation only for their own DOM subtree.
|
|
726
|
+
|
|
727
|
+
Closed roots also cannot be scanned for `<wavelength-route>` declarations.
|
|
728
|
+
Their owning component can register definitions explicitly, then unregister
|
|
729
|
+
them when it disconnects:
|
|
730
|
+
|
|
731
|
+
```typescript
|
|
732
|
+
import { RouteElement, RouterElement } from '@wavelengthusaf/client-router';
|
|
733
|
+
import type { RouteDefinition } from '@wavelengthusaf/client-router';
|
|
734
|
+
|
|
735
|
+
const router = this.closest<RouterElement>('wavelength-router')!;
|
|
736
|
+
const definitions: RouteDefinition[] = Array.from(
|
|
737
|
+
this.#closedRoot.querySelectorAll<RouteElement>('wavelength-route'),
|
|
738
|
+
).flatMap((route) => {
|
|
739
|
+
const definition = route.toDefinition({ document });
|
|
740
|
+
return definition ? [definition] : [];
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
this.#unregisterRoutes = router.registerRoutes(definitions);
|
|
744
|
+
|
|
745
|
+
// In disconnectedCallback():
|
|
746
|
+
this.#unregisterRoutes?.();
|
|
747
|
+
```
|
|
748
|
+
|
|
749
|
+
Registered definitions preserve their registration order and are inserted before
|
|
750
|
+
the first declarative wildcard (`path="*"`). This keeps a declarative fallback
|
|
751
|
+
from making closed-root routes unreachable. Registration captures a snapshot;
|
|
752
|
+
to change closed-root definitions, unregister and register the new array.
|
|
753
|
+
|
|
754
|
+
### Injected Browser Environment
|
|
755
|
+
|
|
756
|
+
Direct `Router` instances accept optional `window` and `document` dependencies
|
|
757
|
+
as their third constructor argument. This is useful for an iframe, a test DOM,
|
|
758
|
+
or an application that needs to avoid implicit global browser access.
|
|
759
|
+
|
|
760
|
+
```typescript
|
|
761
|
+
const frame = document.querySelector('iframe')!;
|
|
762
|
+
const router = new Router(
|
|
763
|
+
routes,
|
|
764
|
+
{ outlet },
|
|
765
|
+
{
|
|
766
|
+
window: frame.contentWindow!,
|
|
767
|
+
document: frame.contentDocument!,
|
|
768
|
+
},
|
|
769
|
+
);
|
|
770
|
+
```
|
|
771
|
+
|
|
772
|
+
### Matching Utilities
|
|
773
|
+
|
|
774
|
+
`compileRoute()` and `matchRoute()` are exported for applications that need to
|
|
775
|
+
inspect the same route patterns without mounting a router.
|
|
776
|
+
|
|
777
|
+
```typescript
|
|
778
|
+
import { compileRoute, matchRoute } from '@wavelengthusaf/client-router';
|
|
779
|
+
|
|
780
|
+
const compiledRoutes = routes.map(compileRoute);
|
|
781
|
+
const match = matchRoute(compiledRoutes, '/users/42');
|
|
782
|
+
|
|
783
|
+
if (match) {
|
|
784
|
+
console.log(match.routeParams.userId); // "42"
|
|
785
|
+
}
|
|
419
786
|
```
|
|
420
787
|
|
|
421
788
|
---
|
|
@@ -529,15 +896,50 @@ routeComponent: {
|
|
|
529
896
|
|
|
530
897
|
---
|
|
531
898
|
|
|
532
|
-
## Development
|
|
899
|
+
## Contributing and Local Development
|
|
900
|
+
|
|
901
|
+
This section is for contributors working in this repository. Application users
|
|
902
|
+
only need the package-installation steps above.
|
|
903
|
+
|
|
904
|
+
### Repository Setup
|
|
905
|
+
|
|
906
|
+
```bash
|
|
907
|
+
git clone git@gitlab.wavelengthusaf.com:common-components/client-router.git
|
|
908
|
+
cd wavelength-router
|
|
909
|
+
npm install
|
|
910
|
+
```
|
|
911
|
+
|
|
912
|
+
Contributors should use Node.js 24 or newer. The GitLab pipeline runs on Node
|
|
913
|
+
24 because it is the current LTS baseline for this project; Node 26 is still a
|
|
914
|
+
Current release. This is documented project policy rather than an install-time
|
|
915
|
+
constraint, because `package.json` does not currently declare an `engines`
|
|
916
|
+
field.
|
|
917
|
+
|
|
918
|
+
### Husky Setup
|
|
919
|
+
|
|
920
|
+
Husky runs the repository's tests and lint checks before commits. It runs during
|
|
921
|
+
`npm install`; if hooks need to be restored manually, run:
|
|
533
922
|
|
|
534
923
|
```bash
|
|
924
|
+
npm run prepare
|
|
925
|
+
```
|
|
926
|
+
|
|
927
|
+
### Development Commands
|
|
928
|
+
|
|
929
|
+
```bash
|
|
930
|
+
# Type check
|
|
931
|
+
npm run type-check
|
|
932
|
+
|
|
535
933
|
# Run tests
|
|
536
934
|
npm run test
|
|
537
935
|
|
|
538
936
|
# Run tests with coverage
|
|
539
937
|
npm run test -- --coverage
|
|
540
938
|
|
|
939
|
+
# Run native-browser integration tests (install Chromium once with
|
|
940
|
+
# `npx playwright install chromium`)
|
|
941
|
+
npm run test:browser
|
|
942
|
+
|
|
541
943
|
# Lint and format
|
|
542
944
|
npm run lint
|
|
543
945
|
npm run prettier
|
|
@@ -545,10 +947,31 @@ npm run prettier
|
|
|
545
947
|
# Build for production
|
|
546
948
|
npm run build
|
|
547
949
|
|
|
950
|
+
# Build Storybook
|
|
951
|
+
npm run build-storybook
|
|
952
|
+
|
|
548
953
|
# Run Storybook for component development
|
|
549
954
|
npm run storybook
|
|
550
955
|
```
|
|
551
956
|
|
|
957
|
+
### CI/CD Expectations
|
|
958
|
+
|
|
959
|
+
GitLab CI installs dependencies, builds the package, runs TypeScript checking,
|
|
960
|
+
linting, unit tests, native-browser integration tests, and security checks.
|
|
961
|
+
Before opening a merge request, run the same local regression set when feasible:
|
|
962
|
+
|
|
963
|
+
```bash
|
|
964
|
+
npm run type-check
|
|
965
|
+
npm run lint
|
|
966
|
+
npm run test
|
|
967
|
+
npm run test:browser
|
|
968
|
+
npm run build
|
|
969
|
+
```
|
|
970
|
+
|
|
971
|
+
Publishing to npm is tag-only. The publish job runs for semantic version tags,
|
|
972
|
+
verifies the package build, skips versions already present on npm, and requires
|
|
973
|
+
the `NPM_AUTH_TOKEN` CI variable.
|
|
974
|
+
|
|
552
975
|
---
|
|
553
976
|
|
|
554
977
|
## License
|