@wavelengthusaf/client-router 0.0.7 → 0.0.8
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 +495 -125
- 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/router/Router.d.ts +24 -1
- package/dist/router/Router.d.ts.map +1 -1
- package/dist/router/Router.js +558 -42
- 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,86 +178,46 @@ 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:
|
|
@@ -288,22 +229,73 @@ Each route requires:
|
|
|
288
229
|
interface RouteDefinition {
|
|
289
230
|
path: string; // e.g., '/users/:id' or '/products/:category/:id'
|
|
290
231
|
routeComponent: {
|
|
291
|
-
mount
|
|
232
|
+
mount: (
|
|
233
|
+
container: HTMLElement,
|
|
234
|
+
context: RouteContext,
|
|
235
|
+
) => void | Promise<void>;
|
|
292
236
|
unmount?(): void; // Optional cleanup function
|
|
293
237
|
};
|
|
294
238
|
}
|
|
295
239
|
```
|
|
296
240
|
|
|
241
|
+
### Router Lifecycle
|
|
242
|
+
|
|
243
|
+
Call `start()` after the outlet exists. Call `destroy()` when the host is
|
|
244
|
+
removed or replaced; it removes navigation listeners and runs the active
|
|
245
|
+
route's optional `unmount()` hook.
|
|
246
|
+
|
|
247
|
+
```typescript
|
|
248
|
+
const router = new Router(routes, { outlet });
|
|
249
|
+
router.start();
|
|
250
|
+
|
|
251
|
+
window.addEventListener('pagehide', () => router.destroy(), { once: true });
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
Each navigation clears the outlet, runs the prior route's `unmount()`, and then
|
|
255
|
+
calls the next route's `mount(outlet, context)`.
|
|
256
|
+
|
|
257
|
+
`mount()` may return a promise. The router waits to mark that route complete
|
|
258
|
+
until the promise resolves. If a newer navigation begins first, the older mount
|
|
259
|
+
is treated as cancelled: it cannot become active or emit a completion event,
|
|
260
|
+
and its optional `unmount()` runs when its promise settles. Route components
|
|
261
|
+
should use their own `AbortController` for work that must be stopped before a
|
|
262
|
+
promise settles.
|
|
263
|
+
|
|
264
|
+
### Direct Router Scoping
|
|
265
|
+
|
|
266
|
+
By default, a direct `Router` listens on its injected `document`. For a nested
|
|
267
|
+
router or a router inside Shadow DOM, `eventTarget` and `navigationRoot` are
|
|
268
|
+
required: without both, multiple document-level routers can handle the same
|
|
269
|
+
composed navigation event. Scope them to the containing host:
|
|
270
|
+
|
|
271
|
+
```typescript
|
|
272
|
+
const host = document.querySelector<HTMLElement>('#account-router')!;
|
|
273
|
+
const outlet = host.querySelector<HTMLElement>('[data-outlet]')!;
|
|
274
|
+
|
|
275
|
+
const router = new Router(routes, {
|
|
276
|
+
outlet,
|
|
277
|
+
eventTarget: host,
|
|
278
|
+
navigationRoot: host,
|
|
279
|
+
});
|
|
280
|
+
router.start();
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
Using the same containing element for `eventTarget` and `navigationRoot`
|
|
284
|
+
prevents an outer router from acting on links owned by a nested router. A
|
|
285
|
+
top-level direct router may omit these options when document-wide navigation is
|
|
286
|
+
intended.
|
|
287
|
+
|
|
297
288
|
---
|
|
298
289
|
|
|
299
290
|
## API Reference
|
|
300
291
|
|
|
301
292
|
### `<wavelength-router>` Attributes
|
|
302
293
|
|
|
303
|
-
| Attribute
|
|
304
|
-
|
|
|
305
|
-
| `hash-mode`
|
|
306
|
-
| `guard`
|
|
294
|
+
| Attribute | Type | Description |
|
|
295
|
+
| ---------------- | --------------------------- | ------------------------------------------------------- |
|
|
296
|
+
| `hash-mode` | flag | Enable hash-based routing (`#/path`) instead of history |
|
|
297
|
+
| `guard` | string | Name of a global guard function on `window` |
|
|
298
|
+
| `focus-strategy` | `none`, `outlet`, `heading` | Focus target after navigation; defaults to `none` |
|
|
307
299
|
|
|
308
300
|
#### Example with Hash Mode
|
|
309
301
|
|
|
@@ -316,6 +308,13 @@ interface RouteDefinition {
|
|
|
316
308
|
|
|
317
309
|
URL becomes: `https://example.com/#/profile`
|
|
318
310
|
|
|
311
|
+
Hash-mode query strings are supported. For `#/profile?tab=security`, the route
|
|
312
|
+
matches `/profile` and `routeContext.query.get('tab')` is `security`.
|
|
313
|
+
External route-hash changes are observed automatically. Hash routes use the
|
|
314
|
+
`#/path` form, so ordinary fragments such as `href="#details"` retain native
|
|
315
|
+
in-page scrolling behavior. Same-origin absolute links ending in `#/path` are
|
|
316
|
+
also normalized as hash routes.
|
|
317
|
+
|
|
319
318
|
### `<wavelength-route>` Attributes
|
|
320
319
|
|
|
321
320
|
| Attribute | Type | Description |
|
|
@@ -323,6 +322,9 @@ URL becomes: `https://example.com/#/profile`
|
|
|
323
322
|
| `path` | string | URL path pattern (supports `:param` syntax) |
|
|
324
323
|
| `component` | string | Custom element tag name to render for this route |
|
|
325
324
|
|
|
325
|
+
Changing either attribute on a connected route automatically rebuilds the
|
|
326
|
+
router's definitions.
|
|
327
|
+
|
|
326
328
|
#### Path Patterns
|
|
327
329
|
|
|
328
330
|
- Static: `/about`, `/dashboard`
|
|
@@ -348,12 +350,10 @@ interface RouteContext {
|
|
|
348
350
|
|
|
349
351
|
```typescript
|
|
350
352
|
class MyPage extends HTMLElement {
|
|
351
|
-
|
|
352
|
-
return (this as unknown as { routeContext?: RouteContext }).routeContext;
|
|
353
|
-
}
|
|
353
|
+
public routeContext?: RouteContext;
|
|
354
354
|
|
|
355
|
-
connectedCallback() {
|
|
356
|
-
const ctx =
|
|
355
|
+
public connectedCallback(): void {
|
|
356
|
+
const ctx = this.routeContext;
|
|
357
357
|
if (ctx) {
|
|
358
358
|
console.log('Current path:', ctx.path);
|
|
359
359
|
console.log('User ID:', ctx.routeParams.userId);
|
|
@@ -401,12 +401,97 @@ type NextCallback = (redirect?: string) => void;
|
|
|
401
401
|
1. Guard receives `RouteContext` with destination path
|
|
402
402
|
2. Guard calls `next()` to allow navigation or `next('/redirect-path')` to redirect
|
|
403
403
|
|
|
404
|
+
Guards may call `next()` asynchronously. If another navigation starts first,
|
|
405
|
+
the older continuation is ignored so it cannot replace the newer route.
|
|
406
|
+
|
|
407
|
+
For a direct `Router`, pass the same guard function as `beforeEach`:
|
|
408
|
+
|
|
409
|
+
```typescript
|
|
410
|
+
const router = new Router(routes, {
|
|
411
|
+
outlet,
|
|
412
|
+
beforeEach: (context, next) => {
|
|
413
|
+
if (context.path.startsWith('/protected') && !getCurrentUser()) {
|
|
414
|
+
next('/login');
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
next();
|
|
419
|
+
},
|
|
420
|
+
});
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
### Route-Level Error Handling
|
|
424
|
+
|
|
425
|
+
Pass `onError` to a direct `Router` to handle route matching, guard, mount, and
|
|
426
|
+
unmount failures in one place. The hook receives the thrown value, its phase,
|
|
427
|
+
and the matched `RouteContext` when one exists. It works in both Light and
|
|
428
|
+
Shadow DOM because it is independent of event retargeting.
|
|
429
|
+
|
|
430
|
+
```typescript
|
|
431
|
+
import type { RouteErrorDetail } from '@wavelengthusaf/client-router';
|
|
432
|
+
|
|
433
|
+
const router = new Router(routes, {
|
|
434
|
+
outlet,
|
|
435
|
+
onError: ({ error, phase, context }: RouteErrorDetail) => {
|
|
436
|
+
reportRouteError(error, { phase, path: context?.path });
|
|
437
|
+
|
|
438
|
+
if (phase === 'mount') {
|
|
439
|
+
outlet.textContent = 'Sorry, this page could not be loaded.';
|
|
440
|
+
}
|
|
441
|
+
},
|
|
442
|
+
});
|
|
443
|
+
```
|
|
444
|
+
|
|
445
|
+
The phases are `match`, `guard`, `mount`, and `unmount`. Supplying `onError`
|
|
446
|
+
handles synchronous guard and mount errors so the router remains started.
|
|
447
|
+
Unmount errors are reported and do not prevent the next route from mounting.
|
|
448
|
+
Without a hook, synchronous guard and mount errors retain their normal thrown
|
|
449
|
+
behavior. `wavelength-route-error` is dispatched in all cases.
|
|
450
|
+
|
|
451
|
+
### Navigation Lifecycle Events
|
|
452
|
+
|
|
453
|
+
The router emits composed, bubbling events for each navigation. Listen on the
|
|
454
|
+
`<wavelength-router>` itself, an ancestor, or the configured `eventTarget` of a
|
|
455
|
+
direct `Router`. They are useful for loading indicators, telemetry, and error
|
|
456
|
+
reporting without coupling page components to router internals.
|
|
457
|
+
|
|
458
|
+
| Event | When it fires | `detail` |
|
|
459
|
+
| --------------------------- | -------------------------------------------------------- | ---------------------------------- |
|
|
460
|
+
| `wavelength-route-start` | A route is matched, before its guard or component mounts | `{ path, context }` |
|
|
461
|
+
| `wavelength-route-complete` | The matched component has mounted successfully | `{ path, context }` |
|
|
462
|
+
| `wavelength-route-error` | Route matching, a guard, mount, or unmount fails | `{ path, context?, error, phase }` |
|
|
463
|
+
|
|
464
|
+
`context` is the normal `RouteContext`. A redirect starts a new navigation for
|
|
465
|
+
its destination and therefore emits completion only for the destination route.
|
|
466
|
+
|
|
467
|
+
```typescript
|
|
468
|
+
import type { RouteLifecycleEventDetail } from '@wavelengthusaf/client-router';
|
|
469
|
+
|
|
470
|
+
document.addEventListener('wavelength-route-start', (event: Event) => {
|
|
471
|
+
const { context } = (event as CustomEvent<RouteLifecycleEventDetail>).detail;
|
|
472
|
+
loadingIndicator.show(context?.path);
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
document.addEventListener('wavelength-route-complete', () => {
|
|
476
|
+
loadingIndicator.hide();
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
document.addEventListener('wavelength-route-error', (event: Event) => {
|
|
480
|
+
const { error, path } = (event as CustomEvent<RouteLifecycleEventDetail>)
|
|
481
|
+
.detail;
|
|
482
|
+
reportNavigationError(path, error);
|
|
483
|
+
});
|
|
484
|
+
```
|
|
485
|
+
|
|
404
486
|
### Programmatic Navigation
|
|
405
487
|
|
|
406
488
|
The router provides navigation methods:
|
|
407
489
|
|
|
408
490
|
```typescript
|
|
409
|
-
|
|
491
|
+
import type { RouterElement } from '@wavelengthusaf/client-router';
|
|
492
|
+
|
|
493
|
+
const routerElement =
|
|
494
|
+
document.querySelector<RouterElement>('wavelength-router')!;
|
|
410
495
|
|
|
411
496
|
// Navigate to a new path (adds to history)
|
|
412
497
|
routerElement.navigate('/about');
|
|
@@ -416,6 +501,235 @@ routerElement.replace('/new-page');
|
|
|
416
501
|
|
|
417
502
|
// Go back in history
|
|
418
503
|
routerElement.back();
|
|
504
|
+
|
|
505
|
+
// Rescan routes after dynamically attaching an open shadow root
|
|
506
|
+
routerElement.refresh();
|
|
507
|
+
```
|
|
508
|
+
|
|
509
|
+
The same methods are available on a `Router` instance. Prefer `replace()` for
|
|
510
|
+
post-login or canonicalization redirects when the previous URL should not
|
|
511
|
+
remain in browser history. Programmatic navigation accepts only valid
|
|
512
|
+
same-origin HTTP(S) paths; external and malformed targets are ignored.
|
|
513
|
+
|
|
514
|
+
### Route Preloading
|
|
515
|
+
|
|
516
|
+
Use the application-controlled `preload` hook to prepare resources for likely
|
|
517
|
+
next pages. The router calls it for eligible same-origin route links on hover
|
|
518
|
+
and keyboard focus, and it receives the matched `RouteContext`. Preloading
|
|
519
|
+
never delays navigation.
|
|
520
|
+
|
|
521
|
+
```typescript
|
|
522
|
+
const router = new Router(routes, {
|
|
523
|
+
outlet,
|
|
524
|
+
preload: async (context) => {
|
|
525
|
+
await pageResourceCache.load(context.path, context.query);
|
|
526
|
+
},
|
|
527
|
+
});
|
|
528
|
+
```
|
|
529
|
+
|
|
530
|
+
`router.preload('/reports?range=month')` starts the same work explicitly.
|
|
531
|
+
`router.getPreloadStatus(path)` returns `idle`, `preloading`, `ready`, or
|
|
532
|
+
`failed`. Pending and successful paths are deduplicated; a failed preload is
|
|
533
|
+
retried by the next explicit call, hover, or focus. A preload failure does not
|
|
534
|
+
affect route navigation or `onError` handling.
|
|
535
|
+
|
|
536
|
+
For declarative routing, configure the property before or after connection:
|
|
537
|
+
|
|
538
|
+
```typescript
|
|
539
|
+
import type { RouterElement } from '@wavelengthusaf/client-router';
|
|
540
|
+
|
|
541
|
+
const routerElement =
|
|
542
|
+
document.querySelector<RouterElement>('wavelength-router')!;
|
|
543
|
+
routerElement.preloader = async (context) => {
|
|
544
|
+
await pageResourceCache.load(context.path, context.query);
|
|
545
|
+
};
|
|
546
|
+
|
|
547
|
+
// Start the same preload work explicitly.
|
|
548
|
+
routerElement.preload('/reports?range=month');
|
|
549
|
+
|
|
550
|
+
// Read the current status: idle, preloading, ready, or failed.
|
|
551
|
+
const status = routerElement.getPreloadStatus('/reports?range=month');
|
|
552
|
+
```
|
|
553
|
+
|
|
554
|
+
The preload machine is intentionally scoped per `Router`. It retains useful
|
|
555
|
+
pending work across ordinary navigations; route components should use their own
|
|
556
|
+
`AbortController` for resource work that must be cancelled.
|
|
557
|
+
|
|
558
|
+
### Focus Management
|
|
559
|
+
|
|
560
|
+
Routing removes the previous page from the outlet. Configure focus management
|
|
561
|
+
when a page change should be announced to keyboard and screen-reader users.
|
|
562
|
+
|
|
563
|
+
```html
|
|
564
|
+
<wavelength-router focus-strategy="heading">
|
|
565
|
+
<wavelength-route path="/" component="home-page"></wavelength-route>
|
|
566
|
+
</wavelength-router>
|
|
567
|
+
```
|
|
568
|
+
|
|
569
|
+
`heading` focuses the first `h1` or level-one ARIA heading in the newly mounted
|
|
570
|
+
page, adding `tabindex="-1"` when needed. `outlet` focuses the render outlet
|
|
571
|
+
itself. If a Light DOM or open Shadow DOM heading is rendered asynchronously,
|
|
572
|
+
the router waits up to five seconds before focusing. Both strategies work
|
|
573
|
+
across Light DOM and open Shadow DOM. For targets that become ready later,
|
|
574
|
+
dispatch `wavelength-focus-ready` as the component-owned focus handoff.
|
|
575
|
+
|
|
576
|
+
An open root attached after navigation cannot be discovered by mutation
|
|
577
|
+
observation alone. When its focus target is ready, dispatch a composed
|
|
578
|
+
`wavelength-focus-ready` event from that target:
|
|
579
|
+
|
|
580
|
+
```typescript
|
|
581
|
+
heading.dispatchEvent(
|
|
582
|
+
new CustomEvent('wavelength-focus-ready', {
|
|
583
|
+
bubbles: true,
|
|
584
|
+
composed: true,
|
|
585
|
+
}),
|
|
586
|
+
);
|
|
587
|
+
```
|
|
588
|
+
|
|
589
|
+
For a closed root, include the target in the event detail; this is an explicit
|
|
590
|
+
component-to-router handoff and does not require exposing the root:
|
|
591
|
+
|
|
592
|
+
```typescript
|
|
593
|
+
heading.dispatchEvent(
|
|
594
|
+
new CustomEvent('wavelength-focus-ready', {
|
|
595
|
+
bubbles: true,
|
|
596
|
+
composed: true,
|
|
597
|
+
detail: { target: heading },
|
|
598
|
+
}),
|
|
599
|
+
);
|
|
600
|
+
```
|
|
601
|
+
|
|
602
|
+
Direct `Router` users can also supply a callback for application-specific
|
|
603
|
+
targets:
|
|
604
|
+
|
|
605
|
+
```typescript
|
|
606
|
+
const router = new Router(routes, {
|
|
607
|
+
outlet,
|
|
608
|
+
focusStrategy: (currentOutlet) =>
|
|
609
|
+
currentOutlet.querySelector<HTMLElement>('[data-route-focus]'),
|
|
610
|
+
});
|
|
611
|
+
```
|
|
612
|
+
|
|
613
|
+
The callback should return a focusable element. Use `focusStrategy: 'none'` to
|
|
614
|
+
retain the default behavior.
|
|
615
|
+
|
|
616
|
+
Closed Shadow DOM is intentionally private. Components that render their focus
|
|
617
|
+
target asynchronously in a closed root should focus that target themselves
|
|
618
|
+
when it is ready.
|
|
619
|
+
|
|
620
|
+
### Shadow DOM Navigation
|
|
621
|
+
|
|
622
|
+
Links inside the router's Light DOM and open Shadow DOM descendants are
|
|
623
|
+
intercepted automatically. Route declarations may also live in open Shadow DOM
|
|
624
|
+
descendants of the router. Each nested router owns navigation from its own DOM
|
|
625
|
+
subtree.
|
|
626
|
+
|
|
627
|
+
Modified clicks, downloads, and links targeting another browsing context keep
|
|
628
|
+
their native browser behavior. Same-document fragment links such as
|
|
629
|
+
`href="#details"` also retain native scrolling behavior. The router handles
|
|
630
|
+
only valid same-origin HTTP(S) destinations.
|
|
631
|
+
|
|
632
|
+
```typescript
|
|
633
|
+
const shell = document.querySelector('app-shell')!;
|
|
634
|
+
const shadowRoot = shell.attachShadow({ mode: 'open' });
|
|
635
|
+
shadowRoot.innerHTML = '<a href="/settings">Settings</a>';
|
|
636
|
+
// The router resolves this link through the event's composed path.
|
|
637
|
+
```
|
|
638
|
+
|
|
639
|
+
If an open root is attached after the router connects, add its route
|
|
640
|
+
declarations and then call `refresh()` so the router can scan and observe it:
|
|
641
|
+
|
|
642
|
+
```typescript
|
|
643
|
+
import type { RouterElement } from '@wavelengthusaf/client-router';
|
|
644
|
+
|
|
645
|
+
const router = document.querySelector<RouterElement>('wavelength-router')!;
|
|
646
|
+
const routeHost = document.querySelector('route-declarations')!;
|
|
647
|
+
const shadowRoot = routeHost.attachShadow({ mode: 'open' });
|
|
648
|
+
shadowRoot.innerHTML =
|
|
649
|
+
'<wavelength-route path="/reports" component="reports-page"></wavelength-route>';
|
|
650
|
+
|
|
651
|
+
router.refresh();
|
|
652
|
+
```
|
|
653
|
+
|
|
654
|
+
Closed Shadow DOM intentionally hides its internal links from event delegation.
|
|
655
|
+
Dispatch a composed `wavelength-navigate` event from the closed-root component
|
|
656
|
+
instead:
|
|
657
|
+
|
|
658
|
+
```typescript
|
|
659
|
+
button.addEventListener('click', () => {
|
|
660
|
+
button.dispatchEvent(
|
|
661
|
+
new CustomEvent('wavelength-navigate', {
|
|
662
|
+
bubbles: true,
|
|
663
|
+
cancelable: true,
|
|
664
|
+
composed: true,
|
|
665
|
+
detail: { path: '/account' },
|
|
666
|
+
}),
|
|
667
|
+
);
|
|
668
|
+
});
|
|
669
|
+
```
|
|
670
|
+
|
|
671
|
+
Set `detail.replace` to `true` to replace the current history entry. Nested
|
|
672
|
+
routers handle navigation only for their own DOM subtree.
|
|
673
|
+
|
|
674
|
+
Closed roots also cannot be scanned for `<wavelength-route>` declarations.
|
|
675
|
+
Their owning component can register definitions explicitly, then unregister
|
|
676
|
+
them when it disconnects:
|
|
677
|
+
|
|
678
|
+
```typescript
|
|
679
|
+
import { RouteElement, RouterElement } from '@wavelengthusaf/client-router';
|
|
680
|
+
import type { RouteDefinition } from '@wavelengthusaf/client-router';
|
|
681
|
+
|
|
682
|
+
const router = this.closest<RouterElement>('wavelength-router')!;
|
|
683
|
+
const definitions: RouteDefinition[] = Array.from(
|
|
684
|
+
this.#closedRoot.querySelectorAll<RouteElement>('wavelength-route'),
|
|
685
|
+
).flatMap((route) => {
|
|
686
|
+
const definition = route.toDefinition({ document });
|
|
687
|
+
return definition ? [definition] : [];
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
this.#unregisterRoutes = router.registerRoutes(definitions);
|
|
691
|
+
|
|
692
|
+
// In disconnectedCallback():
|
|
693
|
+
this.#unregisterRoutes?.();
|
|
694
|
+
```
|
|
695
|
+
|
|
696
|
+
Registered definitions preserve their registration order and are inserted before
|
|
697
|
+
the first declarative wildcard (`path="*"`). This keeps a declarative fallback
|
|
698
|
+
from making closed-root routes unreachable. Registration captures a snapshot;
|
|
699
|
+
to change closed-root definitions, unregister and register the new array.
|
|
700
|
+
|
|
701
|
+
### Injected Browser Environment
|
|
702
|
+
|
|
703
|
+
Direct `Router` instances accept optional `window` and `document` dependencies
|
|
704
|
+
as their third constructor argument. This is useful for an iframe, a test DOM,
|
|
705
|
+
or an application that needs to avoid implicit global browser access.
|
|
706
|
+
|
|
707
|
+
```typescript
|
|
708
|
+
const frame = document.querySelector('iframe')!;
|
|
709
|
+
const router = new Router(
|
|
710
|
+
routes,
|
|
711
|
+
{ outlet },
|
|
712
|
+
{
|
|
713
|
+
window: frame.contentWindow!,
|
|
714
|
+
document: frame.contentDocument!,
|
|
715
|
+
},
|
|
716
|
+
);
|
|
717
|
+
```
|
|
718
|
+
|
|
719
|
+
### Matching Utilities
|
|
720
|
+
|
|
721
|
+
`compileRoute()` and `matchRoute()` are exported for applications that need to
|
|
722
|
+
inspect the same route patterns without mounting a router.
|
|
723
|
+
|
|
724
|
+
```typescript
|
|
725
|
+
import { compileRoute, matchRoute } from '@wavelengthusaf/client-router';
|
|
726
|
+
|
|
727
|
+
const compiledRoutes = routes.map(compileRoute);
|
|
728
|
+
const match = matchRoute(compiledRoutes, '/users/42');
|
|
729
|
+
|
|
730
|
+
if (match) {
|
|
731
|
+
console.log(match.routeParams.userId); // "42"
|
|
732
|
+
}
|
|
419
733
|
```
|
|
420
734
|
|
|
421
735
|
---
|
|
@@ -529,15 +843,50 @@ routeComponent: {
|
|
|
529
843
|
|
|
530
844
|
---
|
|
531
845
|
|
|
532
|
-
## Development
|
|
846
|
+
## Contributing and Local Development
|
|
847
|
+
|
|
848
|
+
This section is for contributors working in this repository. Application users
|
|
849
|
+
only need the package-installation steps above.
|
|
850
|
+
|
|
851
|
+
### Repository Setup
|
|
852
|
+
|
|
853
|
+
```bash
|
|
854
|
+
git clone git@gitlab.wavelengthusaf.com:common-components/client-router.git
|
|
855
|
+
cd wavelength-router
|
|
856
|
+
npm install
|
|
857
|
+
```
|
|
858
|
+
|
|
859
|
+
Contributors should use Node.js 24 or newer. The GitLab pipeline runs on Node
|
|
860
|
+
24 because it is the current LTS baseline for this project; Node 26 is still a
|
|
861
|
+
Current release. This is documented project policy rather than an install-time
|
|
862
|
+
constraint, because `package.json` does not currently declare an `engines`
|
|
863
|
+
field.
|
|
864
|
+
|
|
865
|
+
### Husky Setup
|
|
866
|
+
|
|
867
|
+
Husky runs the repository's tests and lint checks before commits. It runs during
|
|
868
|
+
`npm install`; if hooks need to be restored manually, run:
|
|
869
|
+
|
|
870
|
+
```bash
|
|
871
|
+
npm run prepare
|
|
872
|
+
```
|
|
873
|
+
|
|
874
|
+
### Development Commands
|
|
533
875
|
|
|
534
876
|
```bash
|
|
877
|
+
# Type check
|
|
878
|
+
npm run type-check
|
|
879
|
+
|
|
535
880
|
# Run tests
|
|
536
881
|
npm run test
|
|
537
882
|
|
|
538
883
|
# Run tests with coverage
|
|
539
884
|
npm run test -- --coverage
|
|
540
885
|
|
|
886
|
+
# Run native-browser integration tests (install Chromium once with
|
|
887
|
+
# `npx playwright install chromium`)
|
|
888
|
+
npm run test:browser
|
|
889
|
+
|
|
541
890
|
# Lint and format
|
|
542
891
|
npm run lint
|
|
543
892
|
npm run prettier
|
|
@@ -545,10 +894,31 @@ npm run prettier
|
|
|
545
894
|
# Build for production
|
|
546
895
|
npm run build
|
|
547
896
|
|
|
897
|
+
# Build Storybook
|
|
898
|
+
npm run build-storybook
|
|
899
|
+
|
|
548
900
|
# Run Storybook for component development
|
|
549
901
|
npm run storybook
|
|
550
902
|
```
|
|
551
903
|
|
|
904
|
+
### CI/CD Expectations
|
|
905
|
+
|
|
906
|
+
GitLab CI installs dependencies, builds the package, runs TypeScript checking,
|
|
907
|
+
linting, unit tests, native-browser integration tests, and security checks.
|
|
908
|
+
Before opening a merge request, run the same local regression set when feasible:
|
|
909
|
+
|
|
910
|
+
```bash
|
|
911
|
+
npm run type-check
|
|
912
|
+
npm run lint
|
|
913
|
+
npm run test
|
|
914
|
+
npm run test:browser
|
|
915
|
+
npm run build
|
|
916
|
+
```
|
|
917
|
+
|
|
918
|
+
Publishing to npm is tag-only. The publish job runs for semantic version tags,
|
|
919
|
+
verifies the package build, skips versions already present on npm, and requires
|
|
920
|
+
the `NPM_AUTH_TOKEN` CI variable.
|
|
921
|
+
|
|
552
922
|
---
|
|
553
923
|
|
|
554
924
|
## License
|