@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.
Files changed (36) hide show
  1. package/README.md +555 -132
  2. package/dist/elements/RouteElement.d.ts +3 -0
  3. package/dist/elements/RouteElement.d.ts.map +1 -1
  4. package/dist/elements/RouteElement.js +20 -0
  5. package/dist/elements/RouteElement.js.map +1 -1
  6. package/dist/elements/RouterElement.d.ts +8 -0
  7. package/dist/elements/RouterElement.d.ts.map +1 -1
  8. package/dist/elements/RouterElement.js +176 -20
  9. package/dist/elements/RouterElement.js.map +1 -1
  10. package/dist/index.d.ts +1 -0
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js.map +1 -1
  13. package/dist/machine/createMachine.d.ts +3 -0
  14. package/dist/machine/createMachine.d.ts.map +1 -0
  15. package/dist/machine/createMachine.js +52 -0
  16. package/dist/machine/createMachine.js.map +1 -0
  17. package/dist/machine/index.d.ts +3 -0
  18. package/dist/machine/index.d.ts.map +1 -0
  19. package/dist/machine/index.js +2 -0
  20. package/dist/machine/index.js.map +1 -0
  21. package/dist/machine/machineTypes.d.ts +8 -0
  22. package/dist/machine/machineTypes.d.ts.map +1 -0
  23. package/dist/machine/machineTypes.js +2 -0
  24. package/dist/machine/machineTypes.js.map +1 -0
  25. package/dist/models/RouteComponent.d.ts +1 -1
  26. package/dist/models/RouteComponent.d.ts.map +1 -1
  27. package/dist/models/RouteDefinition.d.ts +2 -0
  28. package/dist/models/RouteDefinition.d.ts.map +1 -1
  29. package/dist/router/Router.d.ts +24 -1
  30. package/dist/router/Router.d.ts.map +1 -1
  31. package/dist/router/Router.js +574 -47
  32. package/dist/router/Router.js.map +1 -1
  33. package/dist/utils/compileRoute.js.map +1 -1
  34. package/dist/utils/matchRoute.d.ts.map +1 -1
  35. package/dist/utils/matchRoute.js.map +1 -1
  36. 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
- - [Husky Setup](#husky-setup)
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 18+ and npm
42
+ - Node.js 24+ and npm
30
43
  - A modern browser with native web components support
31
44
 
32
45
  ### Installation
33
46
 
34
- Clone the repository and install dependencies:
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
- npx husky init
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
- ## Adding to Your Project
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
- <!-- Navigation -->
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>` acts as the router container and outlet
183
- 2. It scans for `<wavelength-route>` children and extracts route definitions
184
- 3. When the URL changes, the router matches the path and mounts the corresponding component
185
- 4. The component is rendered inside the `<wavelength-router>` element
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
- // Define your page components
204
- class HomePage extends HTMLElement {
205
- connectedCallback() {
206
- this.innerHTML = '<h1>Welcome Home</h1>';
207
- }
208
- }
209
-
210
- class AboutPage extends HTMLElement {
211
- connectedCallback() {
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(container: HTMLElement, context: RouteContext) {
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(container: HTMLElement, context: RouteContext) {
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
- // Create and start the router
208
+ const outlet = document.querySelector<HTMLElement>('#app')!;
272
209
  const router = new Router(routes, {
273
- outlet: document.getElementById('app')!, // Container element
274
- hashMode: false, // Use hash routing (optional)
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(container: HTMLElement, context: RouteContext): void;
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 | Type | Description |
304
- | ----------- | ------ | ------------------------------------------------------- |
305
- | `hash-mode` | flag | Enable hash-based routing (`#/path`) instead of history |
306
- | `guard` | string | Name of a global guard function on `window` |
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
- static get routeContext(): RouteContext | undefined {
352
- return (this as unknown as { routeContext?: RouteContext }).routeContext;
353
- }
356
+ public routeContext?: RouteContext;
354
357
 
355
- connectedCallback() {
356
- const ctx = MyPage.routeContext;
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 guard function and reference it via the `guard` attribute.
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="/protected"
393
- component="protected-page"
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
- const routerElement = document.querySelector('wavelength-router');
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