@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 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,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
- // 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:
@@ -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(container: HTMLElement, context: RouteContext): void;
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 | 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` |
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
- static get routeContext(): RouteContext | undefined {
352
- return (this as unknown as { routeContext?: RouteContext }).routeContext;
353
- }
353
+ public routeContext?: RouteContext;
354
354
 
355
- connectedCallback() {
356
- const ctx = MyPage.routeContext;
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
- const routerElement = document.querySelector('wavelength-router');
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