@wavelengthusaf/client-router 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (169) hide show
  1. package/.gitlab-ci.yml +84 -0
  2. package/.husky/pre-commit +38 -0
  3. package/.storybook/main.ts +16 -0
  4. package/.storybook/manager.ts +7 -0
  5. package/.storybook/preview.ts +23 -0
  6. package/.storybook/theme.ts +30 -0
  7. package/.storybook/vitest.setup.ts +7 -0
  8. package/README.md +556 -0
  9. package/dist/elements/RouteElement.d.ts +0 -0
  10. package/dist/elements/RouteElement.d.ts.map +0 -0
  11. package/dist/elements/RouteElement.js +0 -0
  12. package/dist/elements/RouteElement.js.map +0 -0
  13. package/dist/elements/RouterElement.d.ts +0 -0
  14. package/dist/elements/RouterElement.d.ts.map +0 -0
  15. package/dist/elements/RouterElement.js +0 -0
  16. package/dist/elements/RouterElement.js.map +0 -0
  17. package/dist/elements/__tests__/RouteElement.test.d.ts +0 -0
  18. package/dist/elements/__tests__/RouteElement.test.d.ts.map +0 -0
  19. package/dist/elements/__tests__/RouteElement.test.js +77 -0
  20. package/dist/elements/__tests__/RouteElement.test.js.map +0 -0
  21. package/dist/elements/__tests__/RouterElement.test.d.ts +0 -0
  22. package/dist/elements/__tests__/RouterElement.test.d.ts.map +0 -0
  23. package/dist/elements/__tests__/RouterElement.test.js +0 -0
  24. package/dist/elements/__tests__/RouterElement.test.js.map +0 -0
  25. package/dist/index.d.ts +0 -0
  26. package/dist/index.d.ts.map +0 -0
  27. package/dist/index.js +0 -0
  28. package/dist/index.js.map +0 -0
  29. package/dist/models/CompiledRoute.d.ts +0 -0
  30. package/dist/models/CompiledRoute.d.ts.map +0 -0
  31. package/dist/models/CompiledRoute.js +0 -0
  32. package/dist/models/CompiledRoute.js.map +0 -0
  33. package/dist/models/MatchedRoute.d.ts +0 -0
  34. package/dist/models/MatchedRoute.d.ts.map +0 -0
  35. package/dist/models/MatchedRoute.js +0 -0
  36. package/dist/models/MatchedRoute.js.map +0 -0
  37. package/dist/models/RouteComponent.d.ts +0 -0
  38. package/dist/models/RouteComponent.d.ts.map +0 -0
  39. package/dist/models/RouteComponent.js +0 -0
  40. package/dist/models/RouteComponent.js.map +0 -0
  41. package/dist/models/RouteContext.d.ts +0 -0
  42. package/dist/models/RouteContext.d.ts.map +0 -0
  43. package/dist/models/RouteContext.js +0 -0
  44. package/dist/models/RouteContext.js.map +0 -0
  45. package/dist/models/RouteDefinition.d.ts +0 -0
  46. package/dist/models/RouteDefinition.d.ts.map +0 -0
  47. package/dist/models/RouteDefinition.js +0 -0
  48. package/dist/models/RouteDefinition.js.map +0 -0
  49. package/dist/models/RouteGuard.d.ts +0 -0
  50. package/dist/models/RouteGuard.d.ts.map +0 -0
  51. package/dist/models/RouteGuard.js +0 -0
  52. package/dist/models/RouteGuard.js.map +0 -0
  53. package/dist/models/RouteParams.d.ts +0 -0
  54. package/dist/models/RouteParams.d.ts.map +0 -0
  55. package/dist/models/RouteParams.js +0 -0
  56. package/dist/models/RouteParams.js.map +0 -0
  57. package/dist/models/index.d.ts +0 -0
  58. package/dist/models/index.d.ts.map +0 -0
  59. package/dist/models/index.js +0 -0
  60. package/dist/models/index.js.map +0 -0
  61. package/dist/prettier.config.d.ts +0 -0
  62. package/dist/prettier.config.d.ts.map +0 -0
  63. package/dist/router/Router.d.ts +0 -0
  64. package/dist/router/Router.d.ts.map +0 -0
  65. package/dist/router/Router.js +0 -0
  66. package/dist/router/Router.js.map +0 -0
  67. package/dist/router/__tests__/Router.test.d.ts +0 -0
  68. package/dist/router/__tests__/Router.test.d.ts.map +0 -0
  69. package/dist/router/__tests__/Router.test.js +0 -0
  70. package/dist/router/__tests__/Router.test.js.map +0 -0
  71. package/dist/src/elements/RouteElement.d.ts +0 -0
  72. package/dist/src/elements/RouteElement.d.ts.map +0 -0
  73. package/dist/src/elements/RouterElement.d.ts +0 -0
  74. package/dist/src/elements/RouterElement.d.ts.map +0 -0
  75. package/dist/src/elements/__tests__/RouteElement.test.d.ts +0 -0
  76. package/dist/src/elements/__tests__/RouteElement.test.d.ts.map +0 -0
  77. package/dist/src/elements/__tests__/RouterElement.test.d.ts +0 -0
  78. package/dist/src/elements/__tests__/RouterElement.test.d.ts.map +0 -0
  79. package/dist/src/index.d.ts +0 -0
  80. package/dist/src/index.d.ts.map +0 -0
  81. package/dist/src/models/CompiledRoute.d.ts +0 -0
  82. package/dist/src/models/CompiledRoute.d.ts.map +0 -0
  83. package/dist/src/models/MatchedRoute.d.ts +0 -0
  84. package/dist/src/models/MatchedRoute.d.ts.map +0 -0
  85. package/dist/src/models/RouteComponent.d.ts +0 -0
  86. package/dist/src/models/RouteComponent.d.ts.map +0 -0
  87. package/dist/src/models/RouteContext.d.ts +0 -0
  88. package/dist/src/models/RouteContext.d.ts.map +0 -0
  89. package/dist/src/models/RouteDefinition.d.ts +0 -0
  90. package/dist/src/models/RouteDefinition.d.ts.map +0 -0
  91. package/dist/src/models/RouteGuard.d.ts +0 -0
  92. package/dist/src/models/RouteGuard.d.ts.map +0 -0
  93. package/dist/src/models/RouteParams.d.ts +0 -0
  94. package/dist/src/models/RouteParams.d.ts.map +0 -0
  95. package/dist/src/models/index.d.ts +0 -0
  96. package/dist/src/models/index.d.ts.map +0 -0
  97. package/dist/src/router/Router.d.ts +0 -0
  98. package/dist/src/router/Router.d.ts.map +0 -0
  99. package/dist/src/router/__tests__/Router.test.d.ts +0 -0
  100. package/dist/src/router/__tests__/Router.test.d.ts.map +0 -0
  101. package/dist/src/stories/RouterElement.stories.d.ts +0 -0
  102. package/dist/src/stories/RouterElement.stories.d.ts.map +0 -0
  103. package/dist/src/stories/RouterElement.stories.test.d.ts +0 -0
  104. package/dist/src/stories/RouterElement.stories.test.d.ts.map +0 -0
  105. package/dist/src/utils/__tests__/compileRoute.test.d.ts +0 -0
  106. package/dist/src/utils/__tests__/compileRoute.test.d.ts.map +0 -0
  107. package/dist/src/utils/__tests__/matchRoute.test.d.ts +0 -0
  108. package/dist/src/utils/__tests__/matchRoute.test.d.ts.map +0 -0
  109. package/dist/src/utils/compileRoute.d.ts +0 -0
  110. package/dist/src/utils/compileRoute.d.ts.map +0 -0
  111. package/dist/src/utils/index.d.ts +0 -0
  112. package/dist/src/utils/index.d.ts.map +0 -0
  113. package/dist/src/utils/matchRoute.d.ts +0 -0
  114. package/dist/src/utils/matchRoute.d.ts.map +0 -0
  115. package/dist/stories/RouterElement.stories.d.ts +0 -0
  116. package/dist/stories/RouterElement.stories.d.ts.map +0 -0
  117. package/dist/stories/RouterElement.stories.js +0 -0
  118. package/dist/stories/RouterElement.stories.js.map +0 -0
  119. package/dist/utils/__tests__/compileRoute.test.d.ts +0 -0
  120. package/dist/utils/__tests__/compileRoute.test.d.ts.map +0 -0
  121. package/dist/utils/__tests__/compileRoute.test.js +0 -0
  122. package/dist/utils/__tests__/compileRoute.test.js.map +0 -0
  123. package/dist/utils/__tests__/matchRoute.test.d.ts +0 -0
  124. package/dist/utils/__tests__/matchRoute.test.d.ts.map +0 -0
  125. package/dist/utils/__tests__/matchRoute.test.js +0 -0
  126. package/dist/utils/__tests__/matchRoute.test.js.map +0 -0
  127. package/dist/utils/compileRoute.d.ts +0 -0
  128. package/dist/utils/compileRoute.d.ts.map +0 -0
  129. package/dist/utils/compileRoute.js +0 -0
  130. package/dist/utils/compileRoute.js.map +0 -0
  131. package/dist/utils/index.d.ts +0 -0
  132. package/dist/utils/index.d.ts.map +0 -0
  133. package/dist/utils/index.js +0 -0
  134. package/dist/utils/index.js.map +0 -0
  135. package/dist/utils/matchRoute.d.ts +0 -0
  136. package/dist/utils/matchRoute.d.ts.map +0 -0
  137. package/dist/utils/matchRoute.js +0 -0
  138. package/dist/utils/matchRoute.js.map +0 -0
  139. package/eslint.config.mjs +89 -0
  140. package/index.ts +15 -0
  141. package/package.json +54 -0
  142. package/prettier.config.ts +30 -0
  143. package/src/elements/RouteElement.ts +67 -0
  144. package/src/elements/RouterElement.ts +97 -0
  145. package/src/elements/__tests__/RouteElement.test.ts +89 -0
  146. package/src/elements/__tests__/RouterElement.test.ts +253 -0
  147. package/src/index.ts +15 -0
  148. package/src/models/CompiledRoute.ts +7 -0
  149. package/src/models/MatchedRoute.ts +7 -0
  150. package/src/models/RouteComponent.ts +6 -0
  151. package/src/models/RouteContext.ts +7 -0
  152. package/src/models/RouteDefinition.ts +6 -0
  153. package/src/models/RouteGuard.ts +6 -0
  154. package/src/models/RouteParams.ts +3 -0
  155. package/src/models/index.ts +17 -0
  156. package/src/router/Router.ts +225 -0
  157. package/src/router/__tests__/Router.test.ts +464 -0
  158. package/src/stories/RouterElement.stories.test.ts +310 -0
  159. package/src/stories/RouterElement.stories.ts +208 -0
  160. package/src/utils/__tests__/compileRoute.test.ts +97 -0
  161. package/src/utils/__tests__/matchRoute.test.ts +180 -0
  162. package/src/utils/compileRoute.ts +37 -0
  163. package/src/utils/index.ts +4 -0
  164. package/src/utils/matchRoute.ts +37 -0
  165. package/tsconfig.build.json +20 -0
  166. package/tsconfig.json +55 -0
  167. package/vitest.config.ts +34 -0
  168. package/vitest.setup.ts +3 -0
  169. package/vitest.shims.d.ts +1 -0
@@ -0,0 +1,310 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ import * as stories from './RouterElement.stories.ts';
4
+
5
+ vi.mock('../router/Router.ts', () => {
6
+ return {
7
+ Router: class MockRouter {
8
+ public static readonly instances: MockRouter[] = [];
9
+
10
+ public start = vi.fn();
11
+ public destroy = vi.fn();
12
+ public navigate = vi.fn();
13
+ public replace = vi.fn();
14
+ public back = vi.fn();
15
+
16
+ constructor() {
17
+ MockRouter.instances.push(this);
18
+ }
19
+ },
20
+ };
21
+ });
22
+
23
+ describe('RouterElement Stories', () => {
24
+ let container: HTMLDivElement;
25
+ let warnSpy: ReturnType<typeof vi.spyOn>;
26
+
27
+ beforeEach(() => {
28
+ container = document.createElement('div');
29
+ document.body.appendChild(container);
30
+ warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
31
+ });
32
+
33
+ afterEach(() => {
34
+ document.body.removeChild(container);
35
+ warnSpy.mockRestore();
36
+ });
37
+
38
+ describe('Story: Basic', () => {
39
+ it('renders router with two routes', () => {
40
+ const element = stories.Basic.render!() as DocumentFragment;
41
+ container.appendChild(element as unknown as Node);
42
+
43
+ const router = container.querySelector('wavelength-router');
44
+ expect(router).not.toBeNull();
45
+
46
+ const routes = container.querySelectorAll('wavelength-route');
47
+ expect(routes).toHaveLength(2);
48
+ });
49
+
50
+ it('has correct route definitions', () => {
51
+ const element = stories.Basic.render!() as DocumentFragment;
52
+ container.appendChild(element as unknown as Node);
53
+
54
+ const homeRoute = container.querySelector('wavelength-route[path="/"]');
55
+ const aboutRoute = container.querySelector(
56
+ 'wavelength-route[path="/about"]',
57
+ );
58
+
59
+ expect(homeRoute?.getAttribute('component')).toBe('demo-home');
60
+ expect(aboutRoute?.getAttribute('component')).toBe('demo-about');
61
+ });
62
+
63
+ it('displays navigation links', () => {
64
+ const element = stories.Basic.render!() as DocumentFragment;
65
+ container.appendChild(element as unknown as Node);
66
+
67
+ const links = container.querySelectorAll('nav a');
68
+ expect(links).toHaveLength(2);
69
+ expect(links[0]?.getAttribute('href')).toBe('/');
70
+ expect(links[1]?.getAttribute('href')).toBe('/about');
71
+ });
72
+
73
+ it('registers demo components', () => {
74
+ stories.Basic.render!();
75
+
76
+ expect(customElements.get('demo-home')).toBeDefined();
77
+ expect(customElements.get('demo-about')).toBeDefined();
78
+ });
79
+ });
80
+
81
+ describe('Story: HashMode', () => {
82
+ it('renders router with hash-mode attribute', () => {
83
+ const element = stories.HashMode.render!() as DocumentFragment;
84
+ container.appendChild(element as unknown as Node);
85
+
86
+ const router = container.querySelector('wavelength-router[hash-mode]');
87
+ expect(router).not.toBeNull();
88
+ });
89
+
90
+ it('has correct routes for hash mode', () => {
91
+ const element = stories.HashMode.render!() as DocumentFragment;
92
+ container.appendChild(element as unknown as Node);
93
+
94
+ const profileRoute = container.querySelector(
95
+ 'wavelength-route[path="/profile"]',
96
+ );
97
+ expect(profileRoute?.getAttribute('component')).toBe('demo-profile-hash');
98
+ });
99
+
100
+ it('registers unique component tags', () => {
101
+ stories.HashMode.render!();
102
+
103
+ expect(customElements.get('demo-home-hash')).toBeDefined();
104
+ expect(customElements.get('demo-profile-hash')).toBeDefined();
105
+ });
106
+ });
107
+
108
+ describe('Story: WithGuard', () => {
109
+ it('renders router with guard attribute', () => {
110
+ const element = stories.WithGuard.render!() as DocumentFragment;
111
+ container.appendChild(element as unknown as Node);
112
+
113
+ const router = container.querySelector(
114
+ 'wavelength-router[guard="authGuard"]',
115
+ );
116
+ expect(router).not.toBeNull();
117
+ });
118
+
119
+ it('defines authGuard on window', () => {
120
+ stories.WithGuard.render!();
121
+
122
+ expect(
123
+ (window as unknown as Record<string, unknown>).authGuard,
124
+ ).toBeDefined();
125
+ expect(
126
+ typeof (window as unknown as Record<string, unknown>).authGuard,
127
+ ).toBe('function');
128
+ });
129
+
130
+ it('guard redirects /protected to /login', () => {
131
+ stories.WithGuard.render!();
132
+
133
+ const authGuard = (window as unknown as Record<string, unknown>)
134
+ .authGuard as (
135
+ ctx: { path: string },
136
+ next: (redirect?: string) => void,
137
+ ) => void;
138
+
139
+ let redirectedTo: string | undefined;
140
+
141
+ authGuard({ path: '/protected' }, (redirect) => {
142
+ redirectedTo = redirect;
143
+ });
144
+
145
+ expect(redirectedTo).toBe('/login');
146
+ });
147
+
148
+ it('guard allows non-protected routes', () => {
149
+ stories.WithGuard.render!();
150
+
151
+ const authGuard = (window as unknown as Record<string, unknown>)
152
+ .authGuard as (
153
+ ctx: { path: string },
154
+ next: (redirect?: string) => void,
155
+ ) => void;
156
+
157
+ let redirectedTo: string | undefined;
158
+
159
+ authGuard({ path: '/about' }, (redirect) => {
160
+ redirectedTo = redirect;
161
+ });
162
+
163
+ expect(redirectedTo).toBeUndefined();
164
+ });
165
+ });
166
+
167
+ describe('Story: ProgrammaticNavigation', () => {
168
+ it('renders router with navigation button', () => {
169
+ const element = stories.ProgrammaticNavigation
170
+ .render!() as DocumentFragment;
171
+ container.appendChild(element as unknown as Node);
172
+
173
+ const button = container.querySelector('#go-next');
174
+ expect(button).not.toBeNull();
175
+ expect(button?.textContent).toBe('Go to Next');
176
+ });
177
+
178
+ it('router element has navigate method', () => {
179
+ const element = stories.ProgrammaticNavigation
180
+ .render!() as DocumentFragment;
181
+ container.appendChild(element as unknown as Node);
182
+
183
+ const router = container.querySelector(
184
+ 'wavelength-router',
185
+ ) as HTMLElement & {
186
+ navigate(path: string): void;
187
+ };
188
+
189
+ expect(typeof router.navigate).toBe('function');
190
+ });
191
+
192
+ it('button click handler is attached', () => {
193
+ const element = stories.ProgrammaticNavigation
194
+ .render!() as DocumentFragment;
195
+ container.appendChild(element as unknown as Node);
196
+
197
+ const button = container.querySelector('#go-next') as HTMLButtonElement;
198
+ const router = container.querySelector(
199
+ 'wavelength-router',
200
+ ) as HTMLElement & {
201
+ navigate(path: string): void;
202
+ };
203
+
204
+ const navigateSpy = vi.spyOn(router, 'navigate');
205
+
206
+ button.click();
207
+
208
+ expect(navigateSpy).toHaveBeenCalledWith('/next');
209
+ });
210
+ });
211
+
212
+ describe('Story: WithRouteParams', () => {
213
+ it('renders router with parameterized route', () => {
214
+ const element = stories.WithRouteParams.render!() as DocumentFragment;
215
+ container.appendChild(element as unknown as Node);
216
+
217
+ const paramRoute = container.querySelector(
218
+ 'wavelength-route[path="/users/:userId"]',
219
+ );
220
+ expect(paramRoute).not.toBeNull();
221
+ });
222
+
223
+ it('has wildcard route for 404', () => {
224
+ const element = stories.WithRouteParams.render!() as DocumentFragment;
225
+ container.appendChild(element as unknown as Node);
226
+
227
+ const wildcardRoute = container.querySelector(
228
+ 'wavelength-route[path="*"]',
229
+ );
230
+ expect(wildcardRoute).not.toBeNull();
231
+ });
232
+
233
+ it('has navigation links with route params', () => {
234
+ const element = stories.WithRouteParams.render!() as DocumentFragment;
235
+ container.appendChild(element as unknown as Node);
236
+
237
+ const links = container.querySelectorAll('nav a');
238
+ expect(links[0]?.getAttribute('href')).toBe('/users/123');
239
+ expect(links[1]?.getAttribute('href')).toBe('/users/456');
240
+ });
241
+ });
242
+
243
+ describe('Story: NoMatch', () => {
244
+ it('renders router with wildcard route', () => {
245
+ const element = stories.NoMatch.render!() as DocumentFragment;
246
+ container.appendChild(element as unknown as Node);
247
+
248
+ const wildcardRoute = container.querySelector(
249
+ 'wavelength-route[path="*"]',
250
+ );
251
+ expect(wildcardRoute).not.toBeNull();
252
+ });
253
+
254
+ it('has link to non-existent route', () => {
255
+ const element = stories.NoMatch.render!() as DocumentFragment;
256
+ container.appendChild(element as unknown as Node);
257
+
258
+ const link = container.querySelector('a[href="/does-not-exist"]');
259
+ expect(link?.textContent).toBe('Broken Route');
260
+ });
261
+
262
+ it('does not warn about missing routes (wildcard present)', () => {
263
+ stories.NoMatch.render!();
264
+
265
+ expect(warnSpy).not.toHaveBeenCalledWith(
266
+ expect.stringContaining('No <wavelength-route> children found'),
267
+ );
268
+ });
269
+ });
270
+
271
+ describe('Story isolation', () => {
272
+ it('each story creates unique component tags', () => {
273
+ stories.Basic.render!();
274
+ stories.HashMode.render!();
275
+ stories.WithGuard.render!();
276
+
277
+ expect(customElements.get('demo-home')).toBeDefined();
278
+ expect(customElements.get('demo-home-hash')).toBeDefined();
279
+ expect(customElements.get('demo-home-guard')).toBeDefined();
280
+ });
281
+
282
+ it('stories can be rendered multiple times without errors', () => {
283
+ expect(() => {
284
+ const el1 = stories.Basic.render!() as DocumentFragment;
285
+ const el2 = stories.Basic.render!() as DocumentFragment;
286
+ container.appendChild(el1 as unknown as Node);
287
+ container.appendChild(el2 as unknown as Node);
288
+ }).not.toThrow();
289
+ });
290
+ });
291
+
292
+ describe('Story metadata', () => {
293
+ it('meta has correct title', () => {
294
+ expect(stories.default.title).toBe('Router/RouterElement');
295
+ });
296
+
297
+ it('meta references wavelength-router component', () => {
298
+ expect(stories.default.component).toBe('wavelength-router');
299
+ });
300
+
301
+ it('all stories are exported', () => {
302
+ expect(stories.Basic).toBeDefined();
303
+ expect(stories.HashMode).toBeDefined();
304
+ expect(stories.WithGuard).toBeDefined();
305
+ expect(stories.ProgrammaticNavigation).toBeDefined();
306
+ expect(stories.WithRouteParams).toBeDefined();
307
+ expect(stories.NoMatch).toBeDefined();
308
+ });
309
+ });
310
+ });
@@ -0,0 +1,208 @@
1
+ import type { Meta, StoryObj } from '@storybook/web-components-vite';
2
+
3
+ import '../elements/RouteElement.ts';
4
+ import '../elements/RouterElement.ts';
5
+
6
+ const meta: Meta = {
7
+ title: 'Router/RouterElement',
8
+ component: 'wavelength-router',
9
+ tags: ['autodocs'],
10
+ };
11
+
12
+ export default meta;
13
+
14
+ type Story = StoryObj;
15
+
16
+ interface DemoPageProperties {
17
+ title: string;
18
+ content?: string;
19
+ }
20
+
21
+ function createDemoPage(
22
+ tag: string,
23
+ { title, content }: DemoPageProperties,
24
+ ): void {
25
+ if (customElements.get(tag)) return;
26
+
27
+ customElements.define(
28
+ tag,
29
+ class DemoPage extends HTMLElement {
30
+ public connectedCallback() {
31
+ this.textContent = content ?? title;
32
+ }
33
+ },
34
+ );
35
+ }
36
+
37
+ function createRouterContainer(
38
+ routes: { path: string; component: string }[],
39
+ ): string {
40
+ const routeElements = routes
41
+ .map(
42
+ (r) =>
43
+ `<wavelength-route path="${r.path}" component="${r.component}"></wavelength-route>`,
44
+ )
45
+ .join('\n ');
46
+
47
+ return `
48
+ <wavelength-router>
49
+ ${routeElements}
50
+ </wavelength-router>
51
+ `;
52
+ }
53
+
54
+ export const Basic: Story = {
55
+ render: () => {
56
+ const container = document.createElement('div');
57
+
58
+ createDemoPage('demo-home', { title: 'Home Page' });
59
+ createDemoPage('demo-about', { title: 'About Page' });
60
+
61
+ container.innerHTML = `
62
+ ${createRouterContainer([
63
+ { path: '/', component: 'demo-home' },
64
+ { path: '/about', component: 'demo-about' },
65
+ ])}
66
+ <nav style="margin-top: 16px;">
67
+ <a href="/">Home</a>
68
+ <a href="/about" style="margin-left: 8px;">About</a>
69
+ </nav>
70
+ `;
71
+
72
+ return container;
73
+ },
74
+ };
75
+
76
+ export const HashMode: Story = {
77
+ render: () => {
78
+ const container = document.createElement('div');
79
+
80
+ createDemoPage('demo-home-hash', { title: 'Home Page' });
81
+ createDemoPage('demo-profile-hash', { title: 'Profile Page' });
82
+
83
+ container.innerHTML = `
84
+ <wavelength-router hash-mode>
85
+ <wavelength-route path="/" component="demo-home-hash"></wavelength-route>
86
+ <wavelength-route path="/profile" component="demo-profile-hash"></wavelength-route>
87
+ </wavelength-router>
88
+ <nav style="margin-top: 16px;">
89
+ <a href="#/profile">Go to Profile</a>
90
+ </nav>
91
+ `;
92
+
93
+ return container;
94
+ },
95
+ };
96
+
97
+ export const WithGuard: Story = {
98
+ render: () => {
99
+ const container = document.createElement('div');
100
+
101
+ type NextCallback = (redirect?: string) => void;
102
+
103
+ (window as unknown as Record<string, unknown>).authGuard = (
104
+ ctx: { path: string },
105
+ next: NextCallback,
106
+ ): void => {
107
+ if (ctx.path === '/protected') {
108
+ next('/login');
109
+ } else {
110
+ next();
111
+ }
112
+ };
113
+
114
+ createDemoPage('demo-home-guard', { title: 'Home Page' });
115
+ createDemoPage('demo-protected', { title: 'Protected Page' });
116
+ createDemoPage('demo-login', { title: 'Login Page' });
117
+
118
+ container.innerHTML = `
119
+ <wavelength-router guard="authGuard">
120
+ <wavelength-route path="/" component="demo-home-guard"></wavelength-route>
121
+ <wavelength-route path="/protected" component="demo-protected"></wavelength-route>
122
+ <wavelength-route path="/login" component="demo-login"></wavelength-route>
123
+ </wavelength-router>
124
+ <nav style="margin-top: 16px;">
125
+ <a href="/protected">Try Protected Route</a>
126
+ </nav>
127
+ `;
128
+
129
+ return container;
130
+ },
131
+ };
132
+
133
+ export const ProgrammaticNavigation: Story = {
134
+ render: () => {
135
+ const container = document.createElement('div');
136
+
137
+ createDemoPage('demo-home-nav', { title: 'Home Page' });
138
+ createDemoPage('demo-next', { title: 'Next Page' });
139
+
140
+ container.innerHTML = `
141
+ ${createRouterContainer([
142
+ { path: '/', component: 'demo-home-nav' },
143
+ { path: '/next', component: 'demo-next' },
144
+ ])}
145
+ <button id="go-next">Go to Next</button>
146
+ `;
147
+
148
+ const button = container.querySelector('#go-next');
149
+ const router = container.querySelector(
150
+ 'wavelength-router',
151
+ ) as HTMLElement & {
152
+ navigate(path: string): void;
153
+ };
154
+
155
+ if (button && router) {
156
+ button.addEventListener('click', () => {
157
+ router.navigate('/next');
158
+ });
159
+ }
160
+
161
+ return container;
162
+ },
163
+ };
164
+
165
+ export const WithRouteParams: Story = {
166
+ render: () => {
167
+ const container = document.createElement('div');
168
+
169
+ createDemoPage('demo-user', { title: 'User Page' });
170
+ createDemoPage('demo-404', { title: 'Not Found' });
171
+
172
+ container.innerHTML = `
173
+ ${createRouterContainer([
174
+ { path: '/', component: 'demo-user' },
175
+ { path: '/users/:userId', component: 'demo-user' },
176
+ { path: '*', component: 'demo-404' },
177
+ ])}
178
+ <nav style="margin-top: 16px;">
179
+ <a href="/users/123">User 123</a>
180
+ <a href="/users/456" style="margin-left: 8px;">User 456</a>
181
+ <a href="/unknown" style="margin-left: 8px;">404</a>
182
+ </nav>
183
+ `;
184
+
185
+ return container;
186
+ },
187
+ };
188
+
189
+ export const NoMatch: Story = {
190
+ render: () => {
191
+ const container = document.createElement('div');
192
+
193
+ createDemoPage('demo-home-nomatch', { title: 'Home Page' });
194
+ createDemoPage('demo-notfound', { title: 'Page Not Found' });
195
+
196
+ container.innerHTML = `
197
+ ${createRouterContainer([
198
+ { path: '/', component: 'demo-home-nomatch' },
199
+ { path: '*', component: 'demo-notfound' },
200
+ ])}
201
+ <nav style="margin-top: 16px;">
202
+ <a href="/does-not-exist">Broken Route</a>
203
+ </nav>
204
+ `;
205
+
206
+ return container;
207
+ },
208
+ };
@@ -0,0 +1,97 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { compileRoute } from '../compileRoute.ts';
3
+
4
+ describe('compileRoute', () => {
5
+ describe('static routes', () => {
6
+ it.each([
7
+ '/home',
8
+ '/about',
9
+ '/users/list',
10
+ ])('compiles %s correctly', (path) => {
11
+ // @ts-expect-error (passing wrong parameters)
12
+ const result = compileRoute({ path });
13
+
14
+ expect(result.paramNames).toEqual([]);
15
+ expect(result.pattern.test(path)).toBe(true);
16
+ });
17
+ });
18
+
19
+ describe('param routes', () => {
20
+ it.each([
21
+ {
22
+ path: '/users/:id',
23
+ test: '/users/123',
24
+ params: ['id'],
25
+ },
26
+ {
27
+ path: '/posts/:postId/comments/:commentId',
28
+ test: '/posts/1/comments/99',
29
+ params: ['postId', 'commentId'],
30
+ },
31
+ ])('compiles $path', ({ path, test, params }) => {
32
+ // @ts-expect-error (passing wrong parameters)
33
+ const result = compileRoute({ path });
34
+
35
+ expect(result.paramNames).toEqual(params);
36
+ expect(result.pattern.test(test)).toBe(true);
37
+ });
38
+ });
39
+
40
+ describe('wildcard', () => {
41
+ it.each([
42
+ '/',
43
+ '/anything',
44
+ '/deep/nested/path',
45
+ ])('matches all for * route', (test) => {
46
+ // @ts-expect-error (passing wrong parameters)
47
+ const result = compileRoute({ path: '*' });
48
+
49
+ expect(result.paramNames).toEqual([]);
50
+ expect(result.pattern.test(test)).toBe(true);
51
+ });
52
+ });
53
+
54
+ describe('edge cases', () => {
55
+ it.each([
56
+ '/users/:id/',
57
+ '/users/:id/profile',
58
+ '/a/:b/c/:d/e',
59
+ ])('handles %s correctly', (path) => {
60
+ // @ts-expect-error (passing wrong parameters)
61
+ const result = compileRoute({ path });
62
+
63
+ expect(result.pattern).toBeInstanceOf(RegExp);
64
+ expect(result.paramNames.length).toBeGreaterThan(0);
65
+ });
66
+ });
67
+
68
+ describe('regex safety', () => {
69
+ it('escapes special characters', () => {
70
+ // @ts-expect-error (passing wrong parameters)
71
+ const result = compileRoute({ path: '/user.(test)' });
72
+
73
+ expect(result.pattern.test('/user.(test)')).toBe(true);
74
+ });
75
+ });
76
+
77
+ describe('strict matching', () => {
78
+ it('does not match partial paths', () => {
79
+ // @ts-expect-error (passing wrong parameters)
80
+ const result = compileRoute({ path: '/users/:id' });
81
+
82
+ expect(result.pattern.test('/users/123/extra')).toBe(false);
83
+ expect(result.pattern.test('/users')).toBe(false);
84
+ });
85
+ });
86
+
87
+ describe('param extraction', () => {
88
+ it('preserves param order', () => {
89
+ // @ts-expect-error (passing wrong parameters)
90
+ const result = compileRoute({
91
+ path: '/a/:first/b/:second/c/:third',
92
+ });
93
+
94
+ expect(result.paramNames).toEqual(['first', 'second', 'third']);
95
+ });
96
+ });
97
+ });