@minute-spa/vanilla-ui-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.
- package/README.md +645 -0
- package/index.js +113 -0
- package/package.json +19 -0
package/README.md
ADDED
|
@@ -0,0 +1,645 @@
|
|
|
1
|
+
# Vanilla UI Router
|
|
2
|
+
|
|
3
|
+
A lightweight, client-side router for single-page applications (SPAs) that handles navigation, route registration, authentication, and lifecycle management. This is a standalone component of the minute-spa browser SPA application framework (work in progress).
|
|
4
|
+
|
|
5
|
+
Zero dependencies - use in your project via NPM, or simply clone the code right into your project for a truly vanilla experience that requires no packagers or bundlers!
|
|
6
|
+
|
|
7
|
+
## Table of Contents
|
|
8
|
+
|
|
9
|
+
- [Overview](#overview)
|
|
10
|
+
- [Installation and Usage](#installation-and-usage)
|
|
11
|
+
- [Constructor](#constructor)
|
|
12
|
+
- [Public Methods](#public-methods)
|
|
13
|
+
- [Route Configuration](#route-configuration)
|
|
14
|
+
- [Authentication & Authorization](#authentication--authorization)
|
|
15
|
+
- [Navigation Listeners](#navigation-listeners)
|
|
16
|
+
- [Lifecycle Hooks](#lifecycle-hooks)
|
|
17
|
+
- [Complete Examples](#complete-examples)
|
|
18
|
+
|
|
19
|
+
## Overview
|
|
20
|
+
|
|
21
|
+
The `VanillaUiRouter` class provides a simple yet powerful routing solution for vanilla JavaScript applications. It manages:
|
|
22
|
+
|
|
23
|
+
- **Route Registration**: Define paths and associated HTML content
|
|
24
|
+
- **Navigation**: Handle programmatic and browser-based navigation
|
|
25
|
+
- **Authentication**: Optionally implements custom authorization logic
|
|
26
|
+
- **Lifecycle Hooks**: Execute callbacks when pages are rendered or unmounted
|
|
27
|
+
- **History Management**: Integrates with the browser's History API
|
|
28
|
+
- **Navigation Listeners**: Subscribe to route changes
|
|
29
|
+
|
|
30
|
+
## Installation and Usage
|
|
31
|
+
|
|
32
|
+
### For NodeJS projects
|
|
33
|
+
|
|
34
|
+
#### Installation
|
|
35
|
+
|
|
36
|
+
In your project directory, install the dependency on the command line:
|
|
37
|
+
```bash
|
|
38
|
+
npm install --save @minute-spa/vanilla-ui-router
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
#### Usage
|
|
42
|
+
|
|
43
|
+
Import the package in your code:
|
|
44
|
+
```javascript
|
|
45
|
+
import { VanillaUiRouter } from '@minute-spa/vanilla-ui-router'
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### For Vanila projects
|
|
49
|
+
|
|
50
|
+
#### Installation
|
|
51
|
+
|
|
52
|
+
Clone packages/VanillaUiRouter/index.js from https://github.com/devosm1030/minute-spa into your project and rename as appropriate.
|
|
53
|
+
|
|
54
|
+
#### Usage
|
|
55
|
+
|
|
56
|
+
From an ES module, import the package in your code:
|
|
57
|
+
```javascript
|
|
58
|
+
import { VanillaUiRouter } from '<path/to/your/file>'
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Constructor
|
|
62
|
+
|
|
63
|
+
### `new VanillaUiRouter(rootElem, pages = [])`
|
|
64
|
+
|
|
65
|
+
Creates a new VanillaUiRouter instance.
|
|
66
|
+
|
|
67
|
+
#### Parameters
|
|
68
|
+
|
|
69
|
+
| Parameter | Type | Required | Description |
|
|
70
|
+
|-----------|------|----------|-------------|
|
|
71
|
+
| `rootElem` | `HTMLElement` or `string` | Yes | The DOM element or selector where pages will be mounted |
|
|
72
|
+
| `pages` | `Array<{path: string, page: Object}>` | No | Optional array of page configurations to register during initialization |
|
|
73
|
+
|
|
74
|
+
#### Returns
|
|
75
|
+
|
|
76
|
+
A new `VanillaUiRouter` instance.
|
|
77
|
+
|
|
78
|
+
#### Throws
|
|
79
|
+
|
|
80
|
+
- `Error` - If `rootElem` is not provided
|
|
81
|
+
|
|
82
|
+
#### Example
|
|
83
|
+
|
|
84
|
+
```javascript
|
|
85
|
+
const rootElem = document.getElementById('app')
|
|
86
|
+
const homeElem = document.createElement('div')
|
|
87
|
+
homeElem.innerHTML = '<h1>Home</h1>'
|
|
88
|
+
const aboutElem = document.createElement('div')
|
|
89
|
+
aboutElem.innerHTML = '<h1>About</h1>'
|
|
90
|
+
|
|
91
|
+
const pages = [
|
|
92
|
+
{ path: '/home', page: { domElem: homeElem } },
|
|
93
|
+
{ path: '/about', page: { domElem: aboutElem } }
|
|
94
|
+
]
|
|
95
|
+
const router = new VanillaUiRouter(rootElem, pages)
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Public Methods
|
|
99
|
+
|
|
100
|
+
### `registerRoute(path, pageConfig)`
|
|
101
|
+
|
|
102
|
+
Registers a new route with the router.
|
|
103
|
+
|
|
104
|
+
#### Parameters
|
|
105
|
+
|
|
106
|
+
| Parameter | Type | Required | Description |
|
|
107
|
+
|-----------|------|----------|-------------|
|
|
108
|
+
| `path` | `string` | Yes | The URL path (e.g., '/home', '/about') |
|
|
109
|
+
| `pageConfig` | `Object` | Yes | Configuration object for the page |
|
|
110
|
+
| `pageConfig.domElem` | `HTMLElement` or `function` | Yes | DOM element to render, or a function that returns a DOM element |
|
|
111
|
+
| `pageConfig.onRendered` | `function(element)` | No | Callback executed after page is rendered |
|
|
112
|
+
| `pageConfig.onUnmount` | `function(element)` | No | Callback executed before page is removed |
|
|
113
|
+
|
|
114
|
+
#### Returns
|
|
115
|
+
|
|
116
|
+
The `VanillaUiRouter` instance (for method chaining).
|
|
117
|
+
|
|
118
|
+
#### Throws
|
|
119
|
+
|
|
120
|
+
- `Error` - If `path` or `domElem` is missing
|
|
121
|
+
|
|
122
|
+
#### Example
|
|
123
|
+
|
|
124
|
+
```javascript
|
|
125
|
+
const profileElem = document.createElement('div')
|
|
126
|
+
profileElem.id = 'profile'
|
|
127
|
+
profileElem.innerHTML = '<h1>User Profile</h1>'
|
|
128
|
+
|
|
129
|
+
router.registerRoute('/profile', {
|
|
130
|
+
domElem: profileElem,
|
|
131
|
+
onRendered: (element) => {
|
|
132
|
+
console.log('Profile page rendered:', element)
|
|
133
|
+
// Initialize event listeners, load data, etc.
|
|
134
|
+
},
|
|
135
|
+
onUnmount: (element) => {
|
|
136
|
+
console.log('Profile page unmounting:', element)
|
|
137
|
+
// Cleanup event listeners, cancel requests, etc.
|
|
138
|
+
}
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
// Or use a function that returns an element if you want to
|
|
142
|
+
// be able to change pages dynamically between navigations.
|
|
143
|
+
router.registerRoute('/dashboard', {
|
|
144
|
+
domElem: () => {
|
|
145
|
+
const elem = document.createElement('div')
|
|
146
|
+
elem.innerHTML = '<h1>Dashboard</h1>'
|
|
147
|
+
return elem
|
|
148
|
+
}
|
|
149
|
+
})
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
### `initialNav()`
|
|
153
|
+
|
|
154
|
+
Performs the initial navigation based on the current browser URL. Should be called after all routes are registered to render the appropriate page.
|
|
155
|
+
|
|
156
|
+
#### Returns
|
|
157
|
+
|
|
158
|
+
The `VanillaUiRouter` instance (for method chaining).
|
|
159
|
+
|
|
160
|
+
#### Example
|
|
161
|
+
|
|
162
|
+
```javascript
|
|
163
|
+
const homeElem = document.createElement('h1')
|
|
164
|
+
homeElem.textContent = 'Home'
|
|
165
|
+
const aboutElem = document.createElement('h1')
|
|
166
|
+
aboutElem.textContent = 'About'
|
|
167
|
+
|
|
168
|
+
const router = new VanillaUiRouter(document.getElementById('app'))
|
|
169
|
+
.registerRoute('/home', { domElem: homeElem })
|
|
170
|
+
.registerRoute('/about', { domElem: aboutElem })
|
|
171
|
+
.initialNav() // Renders the page matching current URL
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
### `navigateTo(path)`
|
|
175
|
+
|
|
176
|
+
Programmatically navigates to a specified path. Updates the browser URL and renders the corresponding page.
|
|
177
|
+
|
|
178
|
+
#### Parameters
|
|
179
|
+
|
|
180
|
+
| Parameter | Type | Required | Description |
|
|
181
|
+
|-----------|------|----------|-------------|
|
|
182
|
+
| `path` | `string` | Yes | The path to navigate to |
|
|
183
|
+
|
|
184
|
+
#### Returns
|
|
185
|
+
|
|
186
|
+
`undefined`
|
|
187
|
+
|
|
188
|
+
#### Behavior
|
|
189
|
+
|
|
190
|
+
- If the path is invalid and no auth callback is registered, navigation is ignored
|
|
191
|
+
- Updates `window.history` using `pushState`
|
|
192
|
+
- Triggers authentication check if configured
|
|
193
|
+
- Renders the new page
|
|
194
|
+
- Executes all registered navigation listeners
|
|
195
|
+
|
|
196
|
+
#### Example
|
|
197
|
+
|
|
198
|
+
```javascript
|
|
199
|
+
// Navigate to a different page
|
|
200
|
+
router.navigateTo('/about')
|
|
201
|
+
|
|
202
|
+
// Navigate from within an event handler
|
|
203
|
+
document.getElementById('homeBtn').addEventListener('click', () => {
|
|
204
|
+
router.navigateTo('/home')
|
|
205
|
+
})
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
### `onPathAuth(callback)`
|
|
209
|
+
|
|
210
|
+
Optionally registers an authentication/authorization callback that is invoked before rendering any page. If your app does not require authentication/authorization, skip this registration, and all registered pages will be allowed to be navigated to/rendered.
|
|
211
|
+
|
|
212
|
+
#### Parameters
|
|
213
|
+
|
|
214
|
+
| Parameter | Type | Required | Description |
|
|
215
|
+
|-----------|------|----------|-------------|
|
|
216
|
+
| `callback` | `function(path, helpers)` | Yes | Authentication callback function |
|
|
217
|
+
|
|
218
|
+
The callback receives:
|
|
219
|
+
- `path` (string): The path being navigated to
|
|
220
|
+
- `helpers` (object): Helper functions for handling auth results
|
|
221
|
+
- `helpers.redirectTo(path)`: Redirect to a different path
|
|
222
|
+
- `helpers.renderDomElem(element)`: Render a custom DOM element instead
|
|
223
|
+
|
|
224
|
+
#### Callback Return Values
|
|
225
|
+
|
|
226
|
+
- `true` - Allow navigation to the requested path
|
|
227
|
+
- `false` - Block navigation (should call `redirectTo` or `renderDomElem`)
|
|
228
|
+
|
|
229
|
+
#### Returns
|
|
230
|
+
|
|
231
|
+
The `VanillaUiRouter` instance (for method chaining).
|
|
232
|
+
|
|
233
|
+
#### Throws
|
|
234
|
+
|
|
235
|
+
- `Error` - If `redirectTo` is called with an invalid path
|
|
236
|
+
|
|
237
|
+
#### Example
|
|
238
|
+
|
|
239
|
+
```javascript
|
|
240
|
+
router.onPathAuth((path, { redirectTo, renderDomElem }) => {
|
|
241
|
+
// Check if user is authenticated
|
|
242
|
+
if (!isUserLoggedIn() && path !== '/login') {
|
|
243
|
+
// Redirect to login page
|
|
244
|
+
return redirectTo('/login')
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Check if user has permission for this path
|
|
248
|
+
if (path === '/admin' && !isUserAdmin()) {
|
|
249
|
+
// Render unauthorized message in /admin path without changing path in browser
|
|
250
|
+
const unauthorizedElem = document.createElement('h1')
|
|
251
|
+
unauthorizedElem.textContent = 'Unauthorized'
|
|
252
|
+
return renderDomElem(unauthorizedElem)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Allow navigation
|
|
256
|
+
return true
|
|
257
|
+
})
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
### `registerNavListener(callback)`
|
|
261
|
+
|
|
262
|
+
Registers a callback to be executed whenever navigation occurs.
|
|
263
|
+
|
|
264
|
+
#### Parameters
|
|
265
|
+
|
|
266
|
+
| Parameter | Type | Required | Description |
|
|
267
|
+
|-----------|------|----------|-------------|
|
|
268
|
+
| `callback` | `function(path)` | Yes | Callback invoked on navigation |
|
|
269
|
+
|
|
270
|
+
The callback receives:
|
|
271
|
+
- `path` (string): The path that was navigated to
|
|
272
|
+
|
|
273
|
+
#### Returns
|
|
274
|
+
|
|
275
|
+
The `VanillaUiRouter` instance (for method chaining).
|
|
276
|
+
|
|
277
|
+
#### Behavior
|
|
278
|
+
|
|
279
|
+
- If a current path exists when registering, the callback is immediately invoked with that path
|
|
280
|
+
- The callback is invoked after every successful navigation
|
|
281
|
+
|
|
282
|
+
#### Example
|
|
283
|
+
|
|
284
|
+
```javascript
|
|
285
|
+
// Update active nav item
|
|
286
|
+
router.registerNavListener((path) => {
|
|
287
|
+
navbar.activelink(path)
|
|
288
|
+
})
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
### `reauthenticate()`
|
|
292
|
+
|
|
293
|
+
Re-runs the authentication check for the current path. Useful when authentication state changes (e.g., user logs in or out). N/A if you choose not to register an auth callback for your app.
|
|
294
|
+
|
|
295
|
+
#### Returns
|
|
296
|
+
|
|
297
|
+
`undefined`
|
|
298
|
+
|
|
299
|
+
#### Example
|
|
300
|
+
|
|
301
|
+
```javascript
|
|
302
|
+
// After user logs in
|
|
303
|
+
async function login(username, password) {
|
|
304
|
+
const success = await authService.login(username, password)
|
|
305
|
+
if (success) {
|
|
306
|
+
// Re-check authentication for current page
|
|
307
|
+
router.reauthenticate()
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// After user logs out
|
|
312
|
+
function logout() {
|
|
313
|
+
authService.logout()
|
|
314
|
+
router.reauthenticate() // Will redirect to login if needed
|
|
315
|
+
}
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
### `isValidPath(path)`
|
|
319
|
+
|
|
320
|
+
Checks if a given path has been registered with the router.
|
|
321
|
+
|
|
322
|
+
#### Parameters
|
|
323
|
+
|
|
324
|
+
| Parameter | Type | Required | Description |
|
|
325
|
+
|-----------|------|----------|-------------|
|
|
326
|
+
| `path` | `string` | Yes | The path to check |
|
|
327
|
+
|
|
328
|
+
#### Returns
|
|
329
|
+
|
|
330
|
+
`boolean` - `true` if the path is registered, `false` otherwise
|
|
331
|
+
|
|
332
|
+
#### Example
|
|
333
|
+
|
|
334
|
+
```javascript
|
|
335
|
+
if (router.isValidPath('/profile')) {
|
|
336
|
+
router.navigateTo('/profile')
|
|
337
|
+
} else {
|
|
338
|
+
console.log('Profile page not found')
|
|
339
|
+
}
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
## Route Configuration
|
|
343
|
+
|
|
344
|
+
### Page Configuration Object
|
|
345
|
+
|
|
346
|
+
When registering routes, you provide a page configuration object:
|
|
347
|
+
|
|
348
|
+
```javascript
|
|
349
|
+
{
|
|
350
|
+
domElem: HTMLElement | function, // Required: DOM element or function returning DOM element
|
|
351
|
+
onRendered: function(element), // Optional: Called after render
|
|
352
|
+
onUnmount: function(element) // Optional: Called before unmount
|
|
353
|
+
}
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
### DOM Element
|
|
357
|
+
|
|
358
|
+
The `domElem` property can be:
|
|
359
|
+
|
|
360
|
+
1. **HTML Element**:
|
|
361
|
+
```javascript
|
|
362
|
+
const pageElement = document.createElement('div')
|
|
363
|
+
pageElement.innerHTML = '<h1>Welcome</h1>'
|
|
364
|
+
{ domElem: pageElement }
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
2. **Function returning HTML Element**:
|
|
368
|
+
```javascript
|
|
369
|
+
{
|
|
370
|
+
domElem: () => {
|
|
371
|
+
const elem = document.createElement('div')
|
|
372
|
+
elem.innerHTML = '<h1>Welcome</h1>'
|
|
373
|
+
return elem
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
Be sure to sanitize any untrusted content that could be used to build your DOM elements! For a NodeJS project, DomPurify (https://www.npmjs.com/package/dompurify) is a convenient tool for doing this.
|
|
379
|
+
|
|
380
|
+
## Authentication & Authorization
|
|
381
|
+
|
|
382
|
+
Authentication and Authorization only occurs if you have registered an auth callback via **onPathAuth**.
|
|
383
|
+
### Authentication Flow
|
|
384
|
+
|
|
385
|
+
1. User attempts to navigate to a path
|
|
386
|
+
2. Router calls the `onPathAuth` callback (if registered)
|
|
387
|
+
3. Callback decides to:
|
|
388
|
+
- Allow navigation (return `true`)
|
|
389
|
+
- Redirect to another path (call `redirectTo()` and return `false`)
|
|
390
|
+
- Render custom DOM element (call `renderDomElem()` and return `false`)
|
|
391
|
+
|
|
392
|
+
### Example: Protected Routes
|
|
393
|
+
|
|
394
|
+
```javascript
|
|
395
|
+
const publicRoutes = ['/home', '/about', '/login']
|
|
396
|
+
|
|
397
|
+
router.onPathAuth((path, { redirectTo }) => {
|
|
398
|
+
// Allow public routes
|
|
399
|
+
if (publicRoutes.includes(path)) {
|
|
400
|
+
return true
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Check authentication for protected routes
|
|
404
|
+
const token = localStorage.getItem('authToken')
|
|
405
|
+
if (!token) {
|
|
406
|
+
// Store the intended destination
|
|
407
|
+
sessionStorage.setItem('redirectAfterLogin', path)
|
|
408
|
+
return redirectTo('/login')
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
return true
|
|
412
|
+
})
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
### Example: Role-Based Access
|
|
416
|
+
|
|
417
|
+
```javascript
|
|
418
|
+
const adminRoutes = ['/admin', '/users', '/settings']
|
|
419
|
+
|
|
420
|
+
router.onPathAuth((path, { redirectTo, renderDomElem }) => {
|
|
421
|
+
const user = getCurrentUser()
|
|
422
|
+
|
|
423
|
+
if (!user && path !== '/login') {
|
|
424
|
+
return redirectTo('/login')
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
if (adminRoutes.includes(path) && !user.isAdmin) {
|
|
428
|
+
const deniedElem = document.createElement('div')
|
|
429
|
+
deniedElem.innerHTML = '<h1>Access Denied</h1><p>Admin access required.</p>'
|
|
430
|
+
return renderDomElem(deniedElem)
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
return true
|
|
434
|
+
})
|
|
435
|
+
```
|
|
436
|
+
|
|
437
|
+
## Navigation Listeners
|
|
438
|
+
|
|
439
|
+
Navigation listeners are invoked after every successful navigation, making them perfect for:
|
|
440
|
+
|
|
441
|
+
- Updating UI elements (navigation bars, breadcrumbs)
|
|
442
|
+
- Analytics tracking
|
|
443
|
+
- Logging
|
|
444
|
+
- Side effects based on current route
|
|
445
|
+
|
|
446
|
+
### Multiple Listeners
|
|
447
|
+
|
|
448
|
+
You can register multiple navigation listeners:
|
|
449
|
+
|
|
450
|
+
```javascript
|
|
451
|
+
// Update page title
|
|
452
|
+
router.registerNavListener((path) => {
|
|
453
|
+
const titles = {
|
|
454
|
+
'/home': 'Home',
|
|
455
|
+
'/about': 'About Us',
|
|
456
|
+
'/contact': 'Contact'
|
|
457
|
+
}
|
|
458
|
+
document.title = titles[path] || 'My App'
|
|
459
|
+
})
|
|
460
|
+
|
|
461
|
+
// Update navigation state
|
|
462
|
+
router.registerNavListener((path) => {
|
|
463
|
+
updateActiveNavItem(path)
|
|
464
|
+
})
|
|
465
|
+
|
|
466
|
+
// Track page views
|
|
467
|
+
router.registerNavListener((path) => {
|
|
468
|
+
gtag('config', 'GA_MEASUREMENT_ID', {
|
|
469
|
+
page_path: path
|
|
470
|
+
})
|
|
471
|
+
})
|
|
472
|
+
```
|
|
473
|
+
|
|
474
|
+
## Lifecycle Hooks
|
|
475
|
+
|
|
476
|
+
### `onRendered` Hook
|
|
477
|
+
|
|
478
|
+
Called immediately after a page is rendered and added to the DOM.
|
|
479
|
+
|
|
480
|
+
**Use cases:**
|
|
481
|
+
- Initialize event listeners
|
|
482
|
+
- Load data
|
|
483
|
+
- Start animations
|
|
484
|
+
- Focus elements
|
|
485
|
+
- Initialize third-party libraries
|
|
486
|
+
|
|
487
|
+
```javascript
|
|
488
|
+
const dashboardElem = document.createElement('div')
|
|
489
|
+
dashboardElem.id = 'dashboard'
|
|
490
|
+
dashboardElem.innerHTML = '...' // dashboard HTML
|
|
491
|
+
|
|
492
|
+
router.registerRoute('/dashboard', {
|
|
493
|
+
domElem: dashboardElem,
|
|
494
|
+
onRendered: (element) => {
|
|
495
|
+
// Initialize chart library
|
|
496
|
+
const chartCanvas = element.querySelector('#chart')
|
|
497
|
+
initializeChart(chartCanvas)
|
|
498
|
+
|
|
499
|
+
// Load data
|
|
500
|
+
fetchDashboardData().then(data => {
|
|
501
|
+
updateDashboard(element, data)
|
|
502
|
+
})
|
|
503
|
+
|
|
504
|
+
// Add event listeners
|
|
505
|
+
element.querySelector('#refresh-btn').addEventListener('click', refreshData)
|
|
506
|
+
}
|
|
507
|
+
})
|
|
508
|
+
```
|
|
509
|
+
|
|
510
|
+
### `onUnmount` Hook
|
|
511
|
+
|
|
512
|
+
Called just before a page is removed from the DOM.
|
|
513
|
+
|
|
514
|
+
**Use cases:**
|
|
515
|
+
- Remove event listeners
|
|
516
|
+
- Cancel pending requests
|
|
517
|
+
- Clear timers/intervals
|
|
518
|
+
- Cleanup third-party libraries
|
|
519
|
+
- Save state
|
|
520
|
+
|
|
521
|
+
```javascript
|
|
522
|
+
let refreshInterval
|
|
523
|
+
|
|
524
|
+
const feedElem = document.createElement('div')
|
|
525
|
+
feedElem.id = 'feed'
|
|
526
|
+
feedElem.innerHTML = '...' // feed HTML
|
|
527
|
+
|
|
528
|
+
router.registerRoute('/live-feed', {
|
|
529
|
+
domElem: feedElem,
|
|
530
|
+
onRendered: (element) => {
|
|
531
|
+
// Start auto-refresh
|
|
532
|
+
refreshInterval = setInterval(() => {
|
|
533
|
+
updateFeed(element)
|
|
534
|
+
}, 5000)
|
|
535
|
+
},
|
|
536
|
+
onUnmount: (element) => {
|
|
537
|
+
// Stop auto-refresh
|
|
538
|
+
clearInterval(refreshInterval)
|
|
539
|
+
|
|
540
|
+
// Cancel any pending requests
|
|
541
|
+
abortController.abort()
|
|
542
|
+
|
|
543
|
+
console.log('Feed page cleaned up')
|
|
544
|
+
}
|
|
545
|
+
})
|
|
546
|
+
```
|
|
547
|
+
|
|
548
|
+
## Complete Examples
|
|
549
|
+
|
|
550
|
+
### Basic Setup
|
|
551
|
+
|
|
552
|
+
```javascript
|
|
553
|
+
import { VanillaUiRouter } from '@minute-spa/vanilla-ui-router'
|
|
554
|
+
|
|
555
|
+
// Create router
|
|
556
|
+
const router = new VanillaUiRouter(document.getElementById('app'))
|
|
557
|
+
|
|
558
|
+
// Create page elements
|
|
559
|
+
const homeElem = document.createElement('div')
|
|
560
|
+
homeElem.innerHTML = '<h1>Home Page</h1><p>Welcome!</p>'
|
|
561
|
+
|
|
562
|
+
const aboutElem = document.createElement('div')
|
|
563
|
+
aboutElem.innerHTML = '<h1>About Us</h1><p>Learn more about our company.</p>'
|
|
564
|
+
|
|
565
|
+
const contactElem = document.createElement('div')
|
|
566
|
+
contactElem.innerHTML = '<h1>Contact</h1><form>...</form>'
|
|
567
|
+
|
|
568
|
+
// Register routes
|
|
569
|
+
router
|
|
570
|
+
.registerRoute('/home', { domElem: homeElem })
|
|
571
|
+
.registerRoute('/about', { domElem: aboutElem })
|
|
572
|
+
.registerRoute('/contact', { domElem: contactElem })
|
|
573
|
+
|
|
574
|
+
// Start router
|
|
575
|
+
router.initialNav()
|
|
576
|
+
```
|
|
577
|
+
|
|
578
|
+
### Full-Featured Application
|
|
579
|
+
|
|
580
|
+
```javascript
|
|
581
|
+
import { VanillaUiRouter } from '@minute-spa/vanilla-ui-router'
|
|
582
|
+
import { homePage } from './pages/home.js'
|
|
583
|
+
import { profilePage } from './pages/profile.js'
|
|
584
|
+
import { loginPage } from './pages/login.js'
|
|
585
|
+
|
|
586
|
+
// Initialize router with pages
|
|
587
|
+
const pages = [
|
|
588
|
+
{ path: '/home', page: homePage },
|
|
589
|
+
{ path: '/profile', page: profilePage },
|
|
590
|
+
{ path: '/login', page: loginPage }
|
|
591
|
+
]
|
|
592
|
+
|
|
593
|
+
const router = new VanillaUiRouter(document.getElementById('app'), pages)
|
|
594
|
+
|
|
595
|
+
// Add authentication
|
|
596
|
+
router.onPathAuth((path, { redirectTo }) => {
|
|
597
|
+
const isAuthenticated = checkAuthStatus()
|
|
598
|
+
|
|
599
|
+
if (!isAuthenticated && path !== '/login') {
|
|
600
|
+
sessionStorage.setItem('returnTo', path)
|
|
601
|
+
return redirectTo('/login')
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
if (isAuthenticated && path === '/login') {
|
|
605
|
+
const returnTo = sessionStorage.getItem('returnTo') || '/home'
|
|
606
|
+
sessionStorage.removeItem('returnTo')
|
|
607
|
+
return redirectTo(returnTo)
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
return true
|
|
611
|
+
})
|
|
612
|
+
|
|
613
|
+
// Track navigation
|
|
614
|
+
router.registerNavListener((path) => {
|
|
615
|
+
console.log('Navigated to:', path)
|
|
616
|
+
updateBreadcrumbs(path)
|
|
617
|
+
})
|
|
618
|
+
|
|
619
|
+
// Initialize
|
|
620
|
+
router.initialNav()
|
|
621
|
+
```
|
|
622
|
+
|
|
623
|
+
## License
|
|
624
|
+
|
|
625
|
+
MIT License
|
|
626
|
+
|
|
627
|
+
Copyright (c) 2025 Mike DeVos
|
|
628
|
+
|
|
629
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
630
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
631
|
+
in the Software without restriction, including without limitation the rights
|
|
632
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
633
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
634
|
+
furnished to do so, subject to the following conditions:
|
|
635
|
+
|
|
636
|
+
The above copyright notice and this permission notice shall be included in all
|
|
637
|
+
copies or substantial portions of the Software.
|
|
638
|
+
|
|
639
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
640
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
641
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
642
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
643
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
644
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
645
|
+
SOFTWARE.
|
package/index.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/*
|
|
2
|
+
MIT License
|
|
3
|
+
|
|
4
|
+
Copyright (c) 2025 Mike DeVos
|
|
5
|
+
|
|
6
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
7
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
8
|
+
in the Software without restriction, including without limitation the rights
|
|
9
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
10
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
11
|
+
furnished to do so, subject to the following conditions:
|
|
12
|
+
|
|
13
|
+
The above copyright notice and this permission notice shall be included in all
|
|
14
|
+
copies or substantial portions of the Software.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
17
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
18
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
19
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
20
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
21
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
22
|
+
SOFTWARE.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/*
|
|
26
|
+
VanillaUiRouter is a simple client-side router for vanilla JavaScript SPA applications.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
class VanillaUiRouter {
|
|
30
|
+
constructor (rootElem, pages = []) {
|
|
31
|
+
if (!rootElem) throw new Error('Router requires a root element to mount to.')
|
|
32
|
+
Object.assign(this, { rootElem, routes: {}, pageElem: null, pathAuthCb: null, currPath: null, onUnmount: null, navListeners: [] })
|
|
33
|
+
window.onpopstate = () => this.handleNav()
|
|
34
|
+
pages.forEach(({ path, page }) => { this.registerRoute(path, page) })
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
registerRoute (path, { domElem = null, onRendered = null, onUnmount = null } = {}) {
|
|
38
|
+
if (!path || !domElem) throw new Error('Router.registerRoute requires path and an object that includes an html dom element.')
|
|
39
|
+
this.routes[path] = { domElem, onRendered, onUnmount }
|
|
40
|
+
return this
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
registerNavListener (cb) {
|
|
44
|
+
// these callbacks will get called whenever we nav to a new path
|
|
45
|
+
this.navListeners.push(cb)
|
|
46
|
+
if (this.currPath) cb(this.currPath)
|
|
47
|
+
return this
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
initialNav () {
|
|
51
|
+
this.handleNav()
|
|
52
|
+
return this
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
navigateTo (path) {
|
|
56
|
+
if (!this.isValidPath(path) && !this.pathAuthCb) return
|
|
57
|
+
window.history.pushState({}, '', path)
|
|
58
|
+
this.handleNav()
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
onPathAuth (callback) {
|
|
62
|
+
// registration of callback called on navigation to determine if path is authorized
|
|
63
|
+
this.pathAuthCb = callback
|
|
64
|
+
return this
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
isValidPath (path) {
|
|
68
|
+
return !!this.routes[path]
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
pathAuthorized (path) {
|
|
72
|
+
if (!this.pathAuthCb) return true
|
|
73
|
+
return this.pathAuthCb(path, {
|
|
74
|
+
redirectTo: path => {
|
|
75
|
+
if (!this.isValidPath(path)) throw new Error(`Router.pathAuthorized redirectTo path must be valid: ${path}`)
|
|
76
|
+
this.navigateTo(path)
|
|
77
|
+
return false
|
|
78
|
+
},
|
|
79
|
+
renderDomElem: elem => { this.renderDomElem(elem); return false },
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
reauthenticate () {
|
|
84
|
+
this.handleNav()
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
renderDomElem (domElem) {
|
|
88
|
+
if (this.pageElem) {
|
|
89
|
+
if (this.onUnmount) {
|
|
90
|
+
this.onUnmount(this.pageElem)
|
|
91
|
+
this.onUnmount = null
|
|
92
|
+
}
|
|
93
|
+
this.pageElem.remove()
|
|
94
|
+
}
|
|
95
|
+
this.pageElem = (typeof domElem === 'function') ? domElem() : domElem
|
|
96
|
+
this.rootElem.appendChild(this.pageElem)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
handleNav () {
|
|
100
|
+
if (Object.keys(this.routes).length === 0) throw new Error('Router requires at least one registered path')
|
|
101
|
+
const { pathname: path } = new URL(window.location.href)
|
|
102
|
+
const pathIsValid = this.isValidPath(path)
|
|
103
|
+
if (!pathIsValid && !this.pathAuthCb) return this.navigateTo(Object.keys(this.routes)[0])
|
|
104
|
+
if (!this.pathAuthorized(path)) return // false means pathAuthCb must have redirected or rendered html, so don't proceed
|
|
105
|
+
this.renderDomElem(this.routes[path].domElem)
|
|
106
|
+
if (this.routes[path].onRendered) this.routes[path].onRendered(this.pageElem)
|
|
107
|
+
this.onUnmount = this.routes[path].onUnmount || null
|
|
108
|
+
this.currPath = path
|
|
109
|
+
this.navListeners.forEach(cb => cb(path))
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export { VanillaUiRouter }
|
package/package.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@minute-spa/vanilla-ui-router",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "A simple vanilla client side SPA router.",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"author": "Mike DeVos",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"scripts": {
|
|
9
|
+
"publish": "npm publish --access public"
|
|
10
|
+
},
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "git+https://github.com/devosm1030/minute-spa.git"
|
|
14
|
+
},
|
|
15
|
+
"bugs": {
|
|
16
|
+
"url": "https://github.com/devosm1030/minute-spa/issues"
|
|
17
|
+
},
|
|
18
|
+
"homepage": "https://github.com/devosm1030/minute-spa#readme"
|
|
19
|
+
}
|