snice 1.4.0 → 1.6.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.
- package/README.md +239 -237
- package/bin/templates/base/global.d.ts +14 -0
- package/bin/templates/base/src/router.ts +1 -1
- package/package.json +1 -1
- package/src/channel.ts +2 -2
- package/src/controller.ts +10 -1
- package/src/element.ts +75 -8
- package/src/index.ts +2 -2
- package/src/router.ts +157 -51
- package/src/symbols.ts +5 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Snice
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
An imperative TypeScript framework for building vanilla web components with decorators and routing
|
|
4
4
|
|
|
5
5
|
## Quick Start
|
|
6
6
|
|
|
@@ -21,16 +21,16 @@ Snice takes an **imperative approach** to web components. Unlike reactive framew
|
|
|
21
21
|
- Require **explicit method calls** to update visual state
|
|
22
22
|
- Give you **full control** over when and how updates happen
|
|
23
23
|
|
|
24
|
-
This approach
|
|
24
|
+
This approach gives you direct control over DOM updates without hidden complexity or automatic re-renders.
|
|
25
25
|
|
|
26
26
|
## Core Concepts
|
|
27
27
|
|
|
28
28
|
Snice provides a clear separation of concerns through decorators:
|
|
29
29
|
|
|
30
|
-
###
|
|
30
|
+
### Class Decorators
|
|
31
31
|
- **`@element`** - Creates custom HTML elements with encapsulated visual behavior and styling
|
|
32
32
|
- **`@controller`** - Handles data fetching, server communication, and business logic separate from visual components
|
|
33
|
-
- **`@page`** -
|
|
33
|
+
- **`@page`** - Defines routable page components that render when their route is active, with URL params passed as attributes
|
|
34
34
|
|
|
35
35
|
### Property & Query Decorators
|
|
36
36
|
- **`@property`** - Declares properties that can reflect to attributes
|
|
@@ -131,7 +131,7 @@ class UserCard extends HTMLElement {
|
|
|
131
131
|
@property({ reflect: true })
|
|
132
132
|
name = 'Anonymous';
|
|
133
133
|
|
|
134
|
-
@property({
|
|
134
|
+
@property({ attribute: 'user-role' }) // Maps to user-role attribute
|
|
135
135
|
role = 'User';
|
|
136
136
|
|
|
137
137
|
@property({ type: Boolean })
|
|
@@ -152,7 +152,7 @@ class UserCard extends HTMLElement {
|
|
|
152
152
|
|
|
153
153
|
Use it with attributes:
|
|
154
154
|
```html
|
|
155
|
-
<user-card name="Jane Doe" role="Admin" verified></user-card>
|
|
155
|
+
<user-card name="Jane Doe" user-role="Admin" verified></user-card>
|
|
156
156
|
```
|
|
157
157
|
|
|
158
158
|
|
|
@@ -168,56 +168,26 @@ class ThemeToggle extends HTMLElement {
|
|
|
168
168
|
@property({ reflect: true })
|
|
169
169
|
theme: 'light' | 'dark' = 'light';
|
|
170
170
|
|
|
171
|
-
@
|
|
172
|
-
animated = true;
|
|
173
|
-
|
|
174
|
-
@query('.toggle-button')
|
|
175
|
-
button?: HTMLElement;
|
|
176
|
-
|
|
177
|
-
@query('.theme-icon')
|
|
171
|
+
@query('.icon')
|
|
178
172
|
icon?: HTMLElement;
|
|
179
173
|
|
|
180
174
|
html() {
|
|
181
175
|
return `
|
|
182
|
-
<button
|
|
183
|
-
<span class="
|
|
176
|
+
<button>
|
|
177
|
+
<span class="icon">🌞</span>
|
|
184
178
|
</button>
|
|
185
179
|
`;
|
|
186
180
|
}
|
|
187
181
|
|
|
188
182
|
@watch('theme')
|
|
189
|
-
|
|
190
|
-
// propertyName will be 'theme'
|
|
191
|
-
// Update icon when theme changes
|
|
183
|
+
updateTheme(oldValue: string, newValue: string) {
|
|
192
184
|
if (this.icon) {
|
|
193
|
-
this.icon.textContent =
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
// Update button styling
|
|
197
|
-
if (this.button) {
|
|
198
|
-
this.button.classList.remove(`theme--${oldTheme}`);
|
|
199
|
-
this.button.classList.add(`theme--${newTheme}`);
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
// Animate if enabled
|
|
203
|
-
if (this.animated && this.button) {
|
|
204
|
-
this.button.classList.add('transitioning');
|
|
205
|
-
setTimeout(() => {
|
|
206
|
-
this.button?.classList.remove('transitioning');
|
|
207
|
-
}, 300);
|
|
185
|
+
this.icon.textContent = newValue === 'dark' ? '🌙' : '🌞';
|
|
208
186
|
}
|
|
209
187
|
}
|
|
210
188
|
|
|
211
|
-
@
|
|
212
|
-
|
|
213
|
-
// propertyName will be 'animated'
|
|
214
|
-
if (this.button) {
|
|
215
|
-
this.button.classList.toggle('animations-enabled', newValue);
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
@on('click', '.toggle-button')
|
|
220
|
-
toggleTheme() {
|
|
189
|
+
@on('click', 'button')
|
|
190
|
+
toggle() {
|
|
221
191
|
this.theme = this.theme === 'light' ? 'dark' : 'light';
|
|
222
192
|
}
|
|
223
193
|
}
|
|
@@ -234,9 +204,9 @@ You can watch multiple properties with a single decorator:
|
|
|
234
204
|
|
|
235
205
|
```typescript
|
|
236
206
|
@watch('width', 'height', 'scale')
|
|
237
|
-
updateDimensions(
|
|
207
|
+
updateDimensions(_old: number, _new: number, _name: string) {
|
|
238
208
|
// Called when any of these properties change
|
|
239
|
-
console.log(`${
|
|
209
|
+
console.log(`${_name} changed from ${_old} to ${_new}`);
|
|
240
210
|
this.recalculateLayout();
|
|
241
211
|
}
|
|
242
212
|
```
|
|
@@ -245,8 +215,8 @@ Watch all property changes with the wildcard:
|
|
|
245
215
|
|
|
246
216
|
```typescript
|
|
247
217
|
@watch('*')
|
|
248
|
-
handleAnyPropertyChange(
|
|
249
|
-
console.log(`Property ${
|
|
218
|
+
handleAnyPropertyChange(_old: any, _new: any, _name: string) {
|
|
219
|
+
console.log(`Property ${_name} changed from ${_old} to ${_new}`);
|
|
250
220
|
// Useful for debugging or when all properties affect the same output
|
|
251
221
|
}
|
|
252
222
|
```
|
|
@@ -335,24 +305,16 @@ class ToggleSwitch extends HTMLElement {
|
|
|
335
305
|
toggleButton?: HTMLElement;
|
|
336
306
|
|
|
337
307
|
html() {
|
|
338
|
-
return
|
|
339
|
-
<button class="toggle">
|
|
340
|
-
<span class="slider"></span>
|
|
341
|
-
</button>
|
|
342
|
-
`;
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
css() {
|
|
346
|
-
return `...`;
|
|
308
|
+
return `<button class="toggle">OFF</button>`;
|
|
347
309
|
}
|
|
348
310
|
|
|
349
311
|
@on('click', '.toggle')
|
|
350
312
|
@dispatch('toggled')
|
|
351
313
|
toggle() {
|
|
352
314
|
this.isOn = !this.isOn;
|
|
353
|
-
this.toggleButton
|
|
354
|
-
|
|
355
|
-
|
|
315
|
+
if (this.toggleButton) {
|
|
316
|
+
this.toggleButton.textContent = this.isOn ? 'ON' : 'OFF';
|
|
317
|
+
}
|
|
356
318
|
return { on: this.isOn };
|
|
357
319
|
}
|
|
358
320
|
}
|
|
@@ -400,10 +362,7 @@ CSS is automatically scoped to your component.
|
|
|
400
362
|
```typescript
|
|
401
363
|
import { Router } from 'snice';
|
|
402
364
|
|
|
403
|
-
const router = Router({
|
|
404
|
-
target: '#app',
|
|
405
|
-
routing_type: 'hash'
|
|
406
|
-
});
|
|
365
|
+
const router = Router({ target: '#app', type: 'hash' });
|
|
407
366
|
|
|
408
367
|
const { page, navigate, initialize } = router;
|
|
409
368
|
|
|
@@ -439,9 +398,104 @@ initialize();
|
|
|
439
398
|
|
|
440
399
|
// Navigate programmatically
|
|
441
400
|
navigate('/about');
|
|
442
|
-
navigate('/users/123'); // Sets
|
|
401
|
+
navigate('/users/123'); // Sets userId="123" on UserPage
|
|
443
402
|
```
|
|
444
403
|
|
|
404
|
+
### Route Guards
|
|
405
|
+
|
|
406
|
+
Protect routes with guard functions that control access:
|
|
407
|
+
|
|
408
|
+
```typescript
|
|
409
|
+
import { Router, Guard, RouteParams } from 'snice';
|
|
410
|
+
|
|
411
|
+
// Create router with context
|
|
412
|
+
const router = Router({
|
|
413
|
+
target: '#app',
|
|
414
|
+
type: 'hash',
|
|
415
|
+
context: new AppContext(), // Your app's context object
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
const { page, navigate, initialize } = router;
|
|
419
|
+
|
|
420
|
+
// Simple guards (params is empty object for non-parameterized routes)
|
|
421
|
+
const isAuthenticated: Guard<AppContext> = (ctx, params) => ctx.getUser() !== null;
|
|
422
|
+
const isAdmin: Guard<AppContext> = (ctx, params) => ctx.getUser()?.role === 'admin';
|
|
423
|
+
|
|
424
|
+
// Protected page with single guard
|
|
425
|
+
@page({ tag: 'dashboard-page', routes: ['/dashboard'], guards: isAuthenticated })
|
|
426
|
+
class DashboardPage extends HTMLElement {
|
|
427
|
+
html() {
|
|
428
|
+
return `<h1>Dashboard</h1>`;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// Admin page with multiple guards (all must pass)
|
|
433
|
+
@page({ tag: 'admin-page', routes: ['/admin'], guards: [isAuthenticated, isAdmin] })
|
|
434
|
+
class AdminPage extends HTMLElement {
|
|
435
|
+
html() {
|
|
436
|
+
return `<h1>Admin Panel</h1>`;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// Guard that uses route params to check resource-specific permissions
|
|
441
|
+
const canEditUser: Guard<AppContext> = async (ctx, params) => {
|
|
442
|
+
const user = ctx.getUser();
|
|
443
|
+
if (!user) return false;
|
|
444
|
+
|
|
445
|
+
// params.id comes from route '/users/:id/edit'
|
|
446
|
+
const response = await fetch(`/api/permissions/users/${params.id}/can-edit`);
|
|
447
|
+
return response.ok;
|
|
448
|
+
};
|
|
449
|
+
|
|
450
|
+
// Guard that checks ownership
|
|
451
|
+
const ownsItem: Guard<AppContext> = (ctx, params) => {
|
|
452
|
+
const user = ctx.getUser();
|
|
453
|
+
if (!user) return false;
|
|
454
|
+
|
|
455
|
+
// params.itemId comes from route '/items/:itemId'
|
|
456
|
+
return user.ownedItems.includes(parseInt(params.itemId));
|
|
457
|
+
};
|
|
458
|
+
|
|
459
|
+
@page({ tag: 'user-edit', routes: ['/users/:id/edit'], guards: [isAuthenticated, canEditUser] })
|
|
460
|
+
class UserEditPage extends HTMLElement {
|
|
461
|
+
@property()
|
|
462
|
+
id = ''; // Automatically set from route param
|
|
463
|
+
|
|
464
|
+
html() {
|
|
465
|
+
return `<h1>Edit User ${this.id}</h1>`;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
@page({ tag: 'item-view', routes: ['/items/:itemId'], guards: [isAuthenticated, ownsItem] })
|
|
470
|
+
class ItemView extends HTMLElement {
|
|
471
|
+
@property()
|
|
472
|
+
itemId = ''; // Automatically set from route param
|
|
473
|
+
|
|
474
|
+
html() {
|
|
475
|
+
return `<h1>Item ${this.itemId}</h1>`;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// Custom 403 page (optional)
|
|
480
|
+
@page({ tag: 'forbidden-page', routes: ['/403'] })
|
|
481
|
+
class ForbiddenPage extends HTMLElement {
|
|
482
|
+
html() {
|
|
483
|
+
return `
|
|
484
|
+
<h1>Access Denied</h1>
|
|
485
|
+
<p>You don't have permission to view this page.</p>
|
|
486
|
+
<a href="#/">Return to home</a>
|
|
487
|
+
`;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
initialize();
|
|
492
|
+
```
|
|
493
|
+
|
|
494
|
+
When a guard denies access:
|
|
495
|
+
- The 403 page is rendered if defined
|
|
496
|
+
- Otherwise, a default "Unauthorized" message is shown
|
|
497
|
+
- The URL doesn't change (no redirect)
|
|
498
|
+
|
|
445
499
|
## Controllers (Data Fetching)
|
|
446
500
|
|
|
447
501
|
Controllers handle server communication separately from visual components:
|
|
@@ -493,221 +547,169 @@ Use it:
|
|
|
493
547
|
|
|
494
548
|
## Channels
|
|
495
549
|
|
|
496
|
-
|
|
550
|
+
Bidirectional communication between elements and controllers:
|
|
497
551
|
|
|
498
552
|
```typescript
|
|
499
|
-
//
|
|
500
|
-
@element('user-
|
|
501
|
-
class
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
const user = await (yield {
|
|
553
|
+
// Element sends request, controller responds
|
|
554
|
+
@element('user-profile')
|
|
555
|
+
class UserProfile extends HTMLElement {
|
|
556
|
+
|
|
557
|
+
@channel('fetch-user')
|
|
558
|
+
async *getUser() {
|
|
559
|
+
const user = await (yield { userId: 123 });
|
|
506
560
|
return user;
|
|
507
561
|
}
|
|
508
562
|
|
|
509
|
-
async
|
|
510
|
-
const
|
|
511
|
-
|
|
563
|
+
async connectedCallback() {
|
|
564
|
+
const userData = await this.getUser();
|
|
565
|
+
this.displayUser(userData);
|
|
512
566
|
}
|
|
567
|
+
|
|
513
568
|
}
|
|
514
569
|
|
|
515
|
-
// --- controllers/user-controller.ts
|
|
516
570
|
@controller('user-controller')
|
|
517
571
|
class UserController {
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
async
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
@channel('get-data')
|
|
524
|
-
handleGetData(request) {
|
|
525
|
-
console.log(request); // { id: 123 }
|
|
526
|
-
return { name: 'Alice' };
|
|
572
|
+
|
|
573
|
+
@channel('fetch-user')
|
|
574
|
+
async handleFetchUser(request: { userId: number }) {
|
|
575
|
+
const response = await fetch(`/api/users/${request.userId}`);
|
|
576
|
+
return response.json();
|
|
527
577
|
}
|
|
578
|
+
|
|
528
579
|
}
|
|
529
580
|
```
|
|
530
581
|
|
|
531
|
-
##
|
|
582
|
+
## Router Context
|
|
532
583
|
|
|
533
|
-
|
|
584
|
+
Access router context in page components, nested elements, and controllers using the `@context` decorator.
|
|
534
585
|
|
|
535
|
-
|
|
536
|
-
import { element, controller, query } from 'snice';
|
|
586
|
+
### ⚠️ Important Warning About Global State
|
|
537
587
|
|
|
538
|
-
|
|
539
|
-
@element('info-card')
|
|
540
|
-
class InfoCard extends HTMLElement {
|
|
541
|
-
@query('.title')
|
|
542
|
-
titleElement?: HTMLElement;
|
|
543
|
-
|
|
544
|
-
@query('.content')
|
|
545
|
-
contentElement?: HTMLElement;
|
|
546
|
-
|
|
547
|
-
@query('.footer')
|
|
548
|
-
footerElement?: HTMLElement;
|
|
588
|
+
**Context is global shared state and should be treated with extreme caution.** Mutating context from multiple components creates hard-to-debug issues, race conditions, and tightly coupled code. This is a serious footgun if used improperly.
|
|
549
589
|
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
</div>
|
|
557
|
-
<div class="footer"></div>
|
|
558
|
-
</div>
|
|
559
|
-
`;
|
|
560
|
-
}
|
|
590
|
+
**Best practices:**
|
|
591
|
+
- Treat context as **read-only** in components
|
|
592
|
+
- Only mutate context through well-defined methods in the context class
|
|
593
|
+
- Use context for truly global, app-wide state (user auth, theme, locale)
|
|
594
|
+
- For component-specific state, use properties instead
|
|
595
|
+
- Consider the context immutable from the component's perspective
|
|
561
596
|
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
max-width: 400px;
|
|
569
|
-
}
|
|
570
|
-
.title {
|
|
571
|
-
margin: 0 0 15px 0;
|
|
572
|
-
color: #333;
|
|
573
|
-
}
|
|
574
|
-
.content {
|
|
575
|
-
min-height: 60px;
|
|
576
|
-
}
|
|
577
|
-
.skeleton {
|
|
578
|
-
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
|
579
|
-
height: 20px;
|
|
580
|
-
border-radius: 4px;
|
|
581
|
-
animation: loading 1.5s infinite;
|
|
582
|
-
}
|
|
583
|
-
.footer {
|
|
584
|
-
margin-top: 15px;
|
|
585
|
-
font-size: 0.9em;
|
|
586
|
-
color: #666;
|
|
587
|
-
}
|
|
588
|
-
@keyframes loading {
|
|
589
|
-
0% { background-position: -200px 0; }
|
|
590
|
-
100% { background-position: 200px 0; }
|
|
591
|
-
}
|
|
592
|
-
`;
|
|
593
|
-
}
|
|
597
|
+
### Basic Usage in Pages
|
|
598
|
+
|
|
599
|
+
```typescript
|
|
600
|
+
// Define your context class with controlled mutations
|
|
601
|
+
class AppContext {
|
|
602
|
+
private user: User | null = null;
|
|
594
603
|
|
|
595
|
-
//
|
|
596
|
-
|
|
597
|
-
if (this.titleElement) {
|
|
598
|
-
this.titleElement.textContent = title;
|
|
599
|
-
}
|
|
600
|
-
}
|
|
604
|
+
// Controlled mutation through methods
|
|
605
|
+
setUser(user: User) {...}
|
|
601
606
|
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
this.contentElement.innerHTML = html;
|
|
605
|
-
}
|
|
606
|
-
}
|
|
607
|
+
// Read-only access
|
|
608
|
+
getUser() {...}
|
|
607
609
|
|
|
608
|
-
|
|
609
|
-
if (this.footerElement) {
|
|
610
|
-
this.footerElement.textContent = text;
|
|
611
|
-
}
|
|
612
|
-
}
|
|
610
|
+
isAuthenticated() {...}
|
|
613
611
|
}
|
|
614
612
|
|
|
615
|
-
//
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
conditions: 'Partly Cloudy',
|
|
629
|
-
humidity: '65%'
|
|
630
|
-
};
|
|
631
|
-
|
|
632
|
-
// Update the generic card with specific data
|
|
633
|
-
(element as any).setTitle(weatherData.location);
|
|
634
|
-
(element as any).setContent(`
|
|
635
|
-
<p><strong>${weatherData.temp}</strong></p>
|
|
636
|
-
<p>${weatherData.conditions}</p>
|
|
637
|
-
<p>Humidity: ${weatherData.humidity}</p>
|
|
638
|
-
`);
|
|
639
|
-
(element as any).setFooter('Updated just now');
|
|
640
|
-
}
|
|
613
|
+
// Create router with context
|
|
614
|
+
const appContext = new AppContext();
|
|
615
|
+
const { page, initialize } = Router({
|
|
616
|
+
target: '#app',
|
|
617
|
+
type: 'hash',
|
|
618
|
+
context: appContext
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
// Access context in page components
|
|
622
|
+
@page({ tag: 'profile-page', routes: ['/profile'] })
|
|
623
|
+
class ProfilePage extends HTMLElement {
|
|
624
|
+
@context()
|
|
625
|
+
ctx?: AppContext;
|
|
641
626
|
|
|
642
|
-
|
|
643
|
-
//
|
|
627
|
+
html() {
|
|
628
|
+
// READ context, don't mutate it directly
|
|
629
|
+
const user = this.ctx?.getUser();
|
|
630
|
+
if (!user) {
|
|
631
|
+
return `<p>Please log in</p>`;
|
|
632
|
+
}
|
|
633
|
+
return `
|
|
634
|
+
<h1>Welcome, ${user.name}!</h1>
|
|
635
|
+
<p>Email: ${user.email}</p>
|
|
636
|
+
`;
|
|
644
637
|
}
|
|
645
638
|
}
|
|
646
639
|
```
|
|
647
640
|
|
|
648
|
-
|
|
649
|
-
```html
|
|
650
|
-
<!-- Weather widget -->
|
|
651
|
-
<info-card controller="weather-controller"></info-card>
|
|
652
|
-
|
|
653
|
-
<!-- Stock widget - same card, different controller -->
|
|
654
|
-
<info-card controller="stock-controller"></info-card>
|
|
655
|
-
|
|
656
|
-
<!-- News widget - same card, different controller -->
|
|
657
|
-
<info-card controller="news-controller"></info-card>
|
|
658
|
-
```
|
|
659
|
-
|
|
660
|
-
## Decorator Reference
|
|
641
|
+
### Context in Nested Elements
|
|
661
642
|
|
|
662
|
-
|
|
643
|
+
Nested elements within pages can also access context through event bubbling:
|
|
663
644
|
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
645
|
+
```typescript
|
|
646
|
+
// This element can be used inside any page
|
|
647
|
+
@element('user-avatar')
|
|
648
|
+
class UserAvatar extends HTMLElement {
|
|
649
|
+
@context()
|
|
650
|
+
ctx?: AppContext;
|
|
651
|
+
|
|
652
|
+
html() {
|
|
653
|
+
// Context is available even in nested elements
|
|
654
|
+
const user = this.ctx?.getUser();
|
|
655
|
+
return user
|
|
656
|
+
? `<img src="${user.avatar}" alt="${user.name}">`
|
|
657
|
+
: `<div class="placeholder">?</div>`;
|
|
658
|
+
}
|
|
659
|
+
}
|
|
678
660
|
|
|
679
|
-
|
|
661
|
+
// Use it in a page
|
|
662
|
+
@page({ tag: 'dashboard', routes: ['/'] })
|
|
663
|
+
class Dashboard extends HTMLElement {
|
|
664
|
+
html() {
|
|
665
|
+
return `
|
|
666
|
+
<h1>Dashboard</h1>
|
|
667
|
+
<user-avatar></user-avatar> <!-- Will have access to context -->
|
|
668
|
+
`;
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
```
|
|
680
672
|
|
|
681
|
-
|
|
682
|
-
|-----------|---------|---------|
|
|
683
|
-
| `@on(event, selector?)` | Listens for DOM events | `@on('click', '.button')` |
|
|
684
|
-
| `@dispatch(eventName, options?)` | Dispatches custom events after method execution | `@dispatch('data-updated')` |
|
|
685
|
-
| `@channel(name, options?)` | Enables request/response communication | `@channel('fetch-data')` |
|
|
673
|
+
### Context in Controllers
|
|
686
674
|
|
|
687
|
-
|
|
675
|
+
Controllers attached to page elements automatically acquire context:
|
|
688
676
|
|
|
689
677
|
```typescript
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
678
|
+
@controller('nav-controller')
|
|
679
|
+
class NavController {
|
|
680
|
+
element: HTMLElement | null = null;
|
|
681
|
+
|
|
682
|
+
@context()
|
|
683
|
+
ctx?: AppContext;
|
|
684
|
+
|
|
685
|
+
attach(element: HTMLElement) {
|
|
686
|
+
// Context is available in controllers too
|
|
687
|
+
if (!this.ctx?.isAuthenticated()) {
|
|
688
|
+
window.location.hash = '#/login';
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
detach(element: HTMLElement) {
|
|
693
|
+
// Cleanup if needed
|
|
694
|
+
}
|
|
696
695
|
}
|
|
697
696
|
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
697
|
+
@page({ tag: 'admin-page', routes: ['/admin'] })
|
|
698
|
+
class AdminPage extends HTMLElement {
|
|
699
|
+
html() {
|
|
700
|
+
return `<div controller="nav-controller">Admin Panel</div>`;
|
|
701
|
+
}
|
|
701
702
|
}
|
|
702
703
|
```
|
|
703
704
|
|
|
704
|
-
|
|
705
|
+
The `@context` decorator:
|
|
706
|
+
- Injects the router's context into page components
|
|
707
|
+
- Available to nested elements via event bubbling
|
|
708
|
+
- Available to controllers attached to pages
|
|
709
|
+
- Returns the same context instance everywhere
|
|
710
|
+
- Automatically cleaned up when elements are removed
|
|
705
711
|
|
|
706
|
-
|
|
707
|
-
interface DispatchOptions extends EventInit {
|
|
708
|
-
dispatchOnUndefined?: boolean; // Whether to dispatch when method returns undefined
|
|
709
|
-
}
|
|
710
|
-
```
|
|
712
|
+
**Remember:** With great power comes great responsibility. Global state is dangerous - use it wisely and sparingly.
|
|
711
713
|
|
|
712
714
|
## Documentation
|
|
713
715
|
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
declare module '*.css' {
|
|
2
|
+
const content: string;
|
|
3
|
+
export default content;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
declare module '*.css?inline' {
|
|
7
|
+
const content: string;
|
|
8
|
+
export default content;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
declare module '*.html' {
|
|
12
|
+
const content: string;
|
|
13
|
+
export default content;
|
|
14
|
+
}
|
package/package.json
CHANGED
package/src/channel.ts
CHANGED
|
@@ -59,7 +59,7 @@ export function channel(channelName: string, options?: ChannelOptions) {
|
|
|
59
59
|
});
|
|
60
60
|
|
|
61
61
|
// Dispatch event with promises
|
|
62
|
-
const eventName = `@channel
|
|
62
|
+
const eventName = `@channel/${channelName}`;
|
|
63
63
|
const event = new CustomEvent(eventName, {
|
|
64
64
|
bubbles: options?.bubbles !== undefined ? options.bubbles : true,
|
|
65
65
|
cancelable: options?.cancelable || false,
|
|
@@ -135,7 +135,7 @@ export function setupChannelHandlers(instance: any, element: HTMLElement) {
|
|
|
135
135
|
|
|
136
136
|
for (const handler of handlers) {
|
|
137
137
|
const boundMethod = handler.method.bind(instance);
|
|
138
|
-
const eventName = `@channel
|
|
138
|
+
const eventName = `@channel/${handler.channelName}`;
|
|
139
139
|
|
|
140
140
|
// Setup channel handler
|
|
141
141
|
const channelHandler = (event: CustomEvent) => {
|
package/src/controller.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { setupEventHandlers, cleanupEventHandlers } from './events';
|
|
2
2
|
import { setupChannelHandlers, cleanupChannelHandlers } from './channel';
|
|
3
|
-
import { IS_CONTROLLER_CLASS, CONTROLLER_KEY, CONTROLLER_NAME_KEY, CONTROLLER_ID, CONTROLLER_OPERATIONS, NATIVE_CONTROLLER, IS_ELEMENT_CLASS } from './symbols';
|
|
3
|
+
import { IS_CONTROLLER_CLASS, CONTROLLER_KEY, CONTROLLER_NAME_KEY, CONTROLLER_ID, CONTROLLER_OPERATIONS, NATIVE_CONTROLLER, IS_ELEMENT_CLASS, ROUTER_CONTEXT } from './symbols';
|
|
4
4
|
import { snice } from './global';
|
|
5
5
|
|
|
6
6
|
type Maybe<T> = T | null | undefined;
|
|
@@ -110,6 +110,12 @@ export async function attachController(element: HTMLElement, controllerName: str
|
|
|
110
110
|
(controllerInstance as any)[CONTROLLER_ID] = controllerId;
|
|
111
111
|
controllerInstance.element = element;
|
|
112
112
|
|
|
113
|
+
// Pass router context from element to controller if it exists
|
|
114
|
+
const routerContext = (element as any)[ROUTER_CONTEXT];
|
|
115
|
+
if (routerContext !== undefined) {
|
|
116
|
+
(controllerInstance as any)[ROUTER_CONTEXT] = routerContext;
|
|
117
|
+
}
|
|
118
|
+
|
|
113
119
|
// Store references
|
|
114
120
|
(element as any)[CONTROLLER_KEY] = controllerInstance;
|
|
115
121
|
(element as any)[CONTROLLER_NAME_KEY] = controllerName;
|
|
@@ -169,6 +175,9 @@ export async function detachController(element: HTMLElement): Promise<void> {
|
|
|
169
175
|
await scope.cleanup();
|
|
170
176
|
}
|
|
171
177
|
|
|
178
|
+
// Clean up router context reference
|
|
179
|
+
delete (controllerInstance as any)[ROUTER_CONTEXT];
|
|
180
|
+
|
|
172
181
|
delete (element as any)[CONTROLLER_KEY];
|
|
173
182
|
delete (element as any)[CONTROLLER_NAME_KEY];
|
|
174
183
|
delete (element as any)[CONTROLLER_OPERATIONS];
|
package/src/element.ts
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import { attachController, detachController } from './controller';
|
|
2
2
|
import { setupEventHandlers, cleanupEventHandlers } from './events';
|
|
3
|
-
import { IS_ELEMENT_CLASS, READY_PROMISE, READY_RESOLVE, CONTROLLER, PROPERTIES, PROPERTY_VALUES, PROPERTIES_INITIALIZED, PROPERTY_WATCHERS, EXPLICITLY_SET_PROPERTIES } from './symbols';
|
|
3
|
+
import { IS_ELEMENT_CLASS, READY_PROMISE, READY_RESOLVE, CONTROLLER, PROPERTIES, PROPERTY_VALUES, PROPERTIES_INITIALIZED, PROPERTY_WATCHERS, EXPLICITLY_SET_PROPERTIES, ROUTER_CONTEXT } from './symbols';
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
/**
|
|
6
|
+
* Applies core element functionality to a constructor
|
|
7
|
+
* This is shared between @element and @page decorators
|
|
8
|
+
*/
|
|
9
|
+
export function applyElementFunctionality(constructor: any) {
|
|
10
|
+
// Mark as element class for channel decorator detection
|
|
11
|
+
(constructor.prototype as any)[IS_ELEMENT_CLASS] = true;
|
|
9
12
|
|
|
10
13
|
// Add controller property to all elements
|
|
11
14
|
const originalConnectedCallback = constructor.prototype.connectedCallback;
|
|
@@ -179,7 +182,10 @@ export function element(tagName: string) {
|
|
|
179
182
|
this.shadowRoot.innerHTML = shadowContent;
|
|
180
183
|
}
|
|
181
184
|
|
|
182
|
-
|
|
185
|
+
// NOW call the original user-defined connectedCallback after shadow DOM is set up
|
|
186
|
+
if (originalConnectedCallback) {
|
|
187
|
+
originalConnectedCallback.call(this);
|
|
188
|
+
}
|
|
183
189
|
|
|
184
190
|
const controllerName = this.getAttribute('controller');
|
|
185
191
|
if (controllerName) {
|
|
@@ -197,7 +203,10 @@ export function element(tagName: string) {
|
|
|
197
203
|
};
|
|
198
204
|
|
|
199
205
|
constructor.prototype.disconnectedCallback = function() {
|
|
200
|
-
|
|
206
|
+
// Call original user-defined disconnectedCallback first
|
|
207
|
+
if (originalDisconnectedCallback) {
|
|
208
|
+
originalDisconnectedCallback.call(this);
|
|
209
|
+
}
|
|
201
210
|
if (this[CONTROLLER]) {
|
|
202
211
|
detachController(this).catch(error => {
|
|
203
212
|
console.error(`Failed to detach controller:`, error);
|
|
@@ -254,7 +263,11 @@ export function element(tagName: string) {
|
|
|
254
263
|
}
|
|
255
264
|
}
|
|
256
265
|
};
|
|
257
|
-
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export function element(tagName: string) {
|
|
269
|
+
return function (constructor: any) {
|
|
270
|
+
applyElementFunctionality(constructor);
|
|
258
271
|
customElements.define(tagName, constructor);
|
|
259
272
|
};
|
|
260
273
|
}
|
|
@@ -428,4 +441,58 @@ export function watch(...propertyNames: string[]) {
|
|
|
428
441
|
|
|
429
442
|
return descriptor;
|
|
430
443
|
};
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Decorator that injects router context into a property
|
|
448
|
+
* The context is automatically provided to page components by the router
|
|
449
|
+
*/
|
|
450
|
+
export function context() {
|
|
451
|
+
return function(target: any, propertyKey: string) {
|
|
452
|
+
// Define property getter that returns the context
|
|
453
|
+
Object.defineProperty(target, propertyKey, {
|
|
454
|
+
get() {
|
|
455
|
+
// First check if context is stored directly on this element
|
|
456
|
+
if (this[ROUTER_CONTEXT] !== undefined) {
|
|
457
|
+
return this[ROUTER_CONTEXT];
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// Otherwise, request context from parent page via event
|
|
461
|
+
const detail: any = { target: this };
|
|
462
|
+
const event = new CustomEvent('@context/request', {
|
|
463
|
+
bubbles: true,
|
|
464
|
+
cancelable: true,
|
|
465
|
+
detail
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
// Dispatch event and wait for response
|
|
469
|
+
// For controllers, use their element. For elements, dispatch on the host
|
|
470
|
+
let targetElement = this.element || this;
|
|
471
|
+
|
|
472
|
+
// If element is null (e.g., controller was detached), can't get context
|
|
473
|
+
if (!targetElement || !targetElement.dispatchEvent) {
|
|
474
|
+
return undefined;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// If we're in shadow DOM, dispatch on the host element to ensure proper bubbling
|
|
478
|
+
if (targetElement.getRootNode && targetElement.getRootNode() instanceof ShadowRoot) {
|
|
479
|
+
const shadowRoot = targetElement.getRootNode() as ShadowRoot;
|
|
480
|
+
targetElement = shadowRoot.host as HTMLElement;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
targetElement.dispatchEvent(event);
|
|
484
|
+
|
|
485
|
+
// Check if context was provided via the event
|
|
486
|
+
if (detail.context !== undefined) {
|
|
487
|
+
// Cache it for future use
|
|
488
|
+
this[ROUTER_CONTEXT] = detail.context;
|
|
489
|
+
return detail.context;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
return undefined;
|
|
493
|
+
},
|
|
494
|
+
enumerable: true,
|
|
495
|
+
configurable: true
|
|
496
|
+
});
|
|
497
|
+
};
|
|
431
498
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
export { element, customElement, property, query, queryAll, watch } from './element';
|
|
1
|
+
export { element, customElement, property, query, queryAll, watch, context, applyElementFunctionality } from './element';
|
|
2
2
|
export { Router } from './router';
|
|
3
3
|
export { controller, attachController, detachController, getController, useNativeElementControllers, cleanupNativeElementControllers } from './controller';
|
|
4
4
|
export { on, dispatch } from './events';
|
|
5
5
|
export { channel } from './channel';
|
|
6
6
|
export type { PropertyOptions, PropertyConverter } from './element';
|
|
7
|
-
export type { RouterOptions, PageOptions, PageTransition } from './router';
|
|
7
|
+
export type { RouterOptions, PageOptions, PageTransition, Guard, RouteParams, RouterInstance } from './router';
|
|
8
8
|
export type { IController, ControllerClass } from './controller';
|
|
9
9
|
export type { DispatchOptions } from './events';
|
|
10
10
|
export type { ChannelOptions } from './channel';
|
package/src/router.ts
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
import Route from 'route-parser';
|
|
2
|
-
import {
|
|
2
|
+
import { applyElementFunctionality } from './element';
|
|
3
|
+
import { ROUTER_CONTEXT, CONTEXT_REQUEST_HANDLER } from './symbols';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Route parameters extracted from the URL
|
|
7
|
+
*/
|
|
8
|
+
export type RouteParams = Record<string, string>;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Guard function that determines if navigation is allowed
|
|
12
|
+
* @param context The application context
|
|
13
|
+
* @param params The URL parameters from the route
|
|
14
|
+
*/
|
|
15
|
+
export type Guard<T = any> = (context: T, params: RouteParams) => boolean | Promise<boolean>;
|
|
3
16
|
|
|
4
17
|
export interface RouterOptions {
|
|
5
18
|
/**
|
|
@@ -11,7 +24,7 @@ export interface RouterOptions {
|
|
|
11
24
|
/**
|
|
12
25
|
* Whether to use hash routing or push state routing.
|
|
13
26
|
*/
|
|
14
|
-
|
|
27
|
+
type: 'hash' | 'pushstate';
|
|
15
28
|
|
|
16
29
|
/**
|
|
17
30
|
* Override for the window object to use for routing, defaults to global.
|
|
@@ -27,6 +40,11 @@ export interface RouterOptions {
|
|
|
27
40
|
* Global transition configuration for all pages
|
|
28
41
|
*/
|
|
29
42
|
transition?: PageTransition;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Optional context object passed to guard functions
|
|
46
|
+
*/
|
|
47
|
+
context?: any;
|
|
30
48
|
}
|
|
31
49
|
|
|
32
50
|
export interface PageTransition {
|
|
@@ -79,6 +97,23 @@ export interface PageOptions {
|
|
|
79
97
|
* Optional per-page transition override
|
|
80
98
|
*/
|
|
81
99
|
transition?: PageTransition;
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Guard functions that must pass for navigation to proceed.
|
|
103
|
+
* Can be a single guard or an array of guards (all must pass).
|
|
104
|
+
*/
|
|
105
|
+
guards?: Guard<any> | Guard<any>[];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Router return type
|
|
110
|
+
*/
|
|
111
|
+
export interface RouterInstance {
|
|
112
|
+
page: (pageOptions: PageOptions) => <C extends { new(...args: any[]): HTMLElement }>(constructor: C) => void;
|
|
113
|
+
initialize: () => void;
|
|
114
|
+
navigate: (path: string) => Promise<void>;
|
|
115
|
+
register: (route: string, tag: string, transition?: PageTransition, guards?: Guard<any> | Guard<any>[]) => void;
|
|
116
|
+
context: any;
|
|
82
117
|
}
|
|
83
118
|
|
|
84
119
|
/**
|
|
@@ -86,13 +121,15 @@ export interface PageOptions {
|
|
|
86
121
|
* @param {RouterOptions} options - The router configuration options.
|
|
87
122
|
* @returns An object containing the router's API methods.
|
|
88
123
|
*/
|
|
89
|
-
export function Router(options: RouterOptions) {
|
|
90
|
-
const routes: { route: Route, tag: string, transition?: PageTransition }[] = [];
|
|
124
|
+
export function Router(options: RouterOptions): RouterInstance {
|
|
125
|
+
const routes: { route: Route, tag: string, transition?: PageTransition, guards?: Guard<any> | Guard<any>[] }[] = [];
|
|
91
126
|
let is_sorted = false;
|
|
92
127
|
|
|
93
128
|
let _404: string; // the 404 page
|
|
129
|
+
let _403: string; // the 403 forbidden page
|
|
94
130
|
let home: string; // the home page
|
|
95
131
|
let currentPageElement: HTMLElement | null = null; // Track current page for transitions
|
|
132
|
+
const context = options.context || {}; // Store context for guards
|
|
96
133
|
|
|
97
134
|
/**
|
|
98
135
|
* Decorator function for defining a page with associated routes.
|
|
@@ -100,63 +137,55 @@ export function Router(options: RouterOptions) {
|
|
|
100
137
|
* @returns A decorator function to apply to a custom element class.
|
|
101
138
|
*/
|
|
102
139
|
function page(pageOptions: PageOptions) {
|
|
103
|
-
return function <
|
|
140
|
+
return function <C extends { new(...args: any[]): HTMLElement }>(constructor: C) {
|
|
141
|
+
// Apply all element functionality (properties, queries, watchers, controllers, etc.)
|
|
142
|
+
applyElementFunctionality(constructor);
|
|
143
|
+
|
|
104
144
|
// Store transition config on constructor for later use
|
|
105
145
|
(constructor as any).__transition = pageOptions.transition;
|
|
106
|
-
// Add event handler support
|
|
107
|
-
const originalConnectedCallback = constructor.prototype.connectedCallback;
|
|
108
|
-
const originalDisconnectedCallback = constructor.prototype.disconnectedCallback;
|
|
109
146
|
|
|
147
|
+
// Extend the connectedCallback to add router-specific functionality
|
|
148
|
+
const elementConnectedCallback = constructor.prototype.connectedCallback;
|
|
110
149
|
constructor.prototype.connectedCallback = function() {
|
|
111
|
-
// Call
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
// Create shadow root if it doesn't exist
|
|
115
|
-
if (!this.shadowRoot) {
|
|
116
|
-
this.attachShadow({ mode: 'open' });
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
// Build the shadow DOM content
|
|
120
|
-
let shadowContent = '';
|
|
150
|
+
// Call the element's connectedCallback first
|
|
151
|
+
elementConnectedCallback?.call(this);
|
|
121
152
|
|
|
122
|
-
//
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
if (
|
|
126
|
-
|
|
153
|
+
// Setup context request handler for nested elements
|
|
154
|
+
const contextRequestHandler = (event: any) => {
|
|
155
|
+
// Only respond if this element has context
|
|
156
|
+
if (this[ROUTER_CONTEXT] !== undefined) {
|
|
157
|
+
event.detail.context = this[ROUTER_CONTEXT];
|
|
158
|
+
event.stopPropagation(); // Stop bubbling once context is provided
|
|
127
159
|
}
|
|
128
|
-
}
|
|
160
|
+
};
|
|
161
|
+
this.addEventListener('@context/request', contextRequestHandler);
|
|
129
162
|
|
|
130
|
-
//
|
|
131
|
-
|
|
132
|
-
const cssResult = this.css();
|
|
133
|
-
if (cssResult) {
|
|
134
|
-
// Handle both string and array of strings
|
|
135
|
-
const cssContent = Array.isArray(cssResult) ? cssResult.join('\n') : cssResult;
|
|
136
|
-
// No need for scoping with Shadow DOM, but add data attribute for compatibility
|
|
137
|
-
shadowContent += `<style data-component-css>${cssContent}</style>`;
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
// Set shadow DOM content
|
|
142
|
-
if (shadowContent) {
|
|
143
|
-
this.shadowRoot.innerHTML = shadowContent;
|
|
144
|
-
}
|
|
145
|
-
// Setup @on event handlers - use element for host events, shadow root for delegated events
|
|
146
|
-
setupEventHandlers(this, this);
|
|
163
|
+
// Store handler for cleanup
|
|
164
|
+
(this as any)[CONTEXT_REQUEST_HANDLER] = contextRequestHandler;
|
|
147
165
|
};
|
|
148
166
|
|
|
167
|
+
// Extend the disconnectedCallback to clean up router-specific stuff
|
|
168
|
+
const elementDisconnectedCallback = constructor.prototype.disconnectedCallback;
|
|
149
169
|
constructor.prototype.disconnectedCallback = function() {
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
170
|
+
// Call element's disconnectedCallback first
|
|
171
|
+
elementDisconnectedCallback?.call(this);
|
|
172
|
+
|
|
173
|
+
// Clean up context request handler
|
|
174
|
+
const handler = (this as any)[CONTEXT_REQUEST_HANDLER];
|
|
175
|
+
if (handler) {
|
|
176
|
+
this.removeEventListener('@context/request', handler);
|
|
177
|
+
delete (this as any)[CONTEXT_REQUEST_HANDLER];
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Clean up context reference
|
|
181
|
+
delete (this as any)[ROUTER_CONTEXT];
|
|
153
182
|
};
|
|
154
183
|
|
|
155
184
|
// Define the custom element
|
|
156
185
|
customElements.define(pageOptions.tag, constructor);
|
|
157
186
|
|
|
158
|
-
// Register the routes
|
|
159
|
-
pageOptions.routes.forEach(route => register(route, pageOptions.tag));
|
|
187
|
+
// Register the routes with guards
|
|
188
|
+
pageOptions.routes.forEach(route => register(route, pageOptions.tag, pageOptions.transition, pageOptions.guards));
|
|
160
189
|
}
|
|
161
190
|
}
|
|
162
191
|
|
|
@@ -167,13 +196,17 @@ export function Router(options: RouterOptions) {
|
|
|
167
196
|
* @example
|
|
168
197
|
* register('/custom-route', 'custom-element');
|
|
169
198
|
*/
|
|
170
|
-
function register(route: string, tag: string, transition?: PageTransition): void {
|
|
171
|
-
routes.push({ route: new Route(route), tag, transition });
|
|
199
|
+
function register(route: string, tag: string, transition?: PageTransition, guards?: Guard<any> | Guard<any>[]): void {
|
|
200
|
+
routes.push({ route: new Route(route), tag, transition, guards });
|
|
172
201
|
is_sorted = false;
|
|
173
202
|
|
|
174
203
|
if (route === '/404') {
|
|
175
204
|
_404 = tag;
|
|
176
205
|
}
|
|
206
|
+
|
|
207
|
+
if (route === '/403') {
|
|
208
|
+
_403 = tag;
|
|
209
|
+
}
|
|
177
210
|
|
|
178
211
|
if (route === '/') {
|
|
179
212
|
home = tag;
|
|
@@ -197,7 +230,7 @@ export function Router(options: RouterOptions) {
|
|
|
197
230
|
}
|
|
198
231
|
|
|
199
232
|
// Listen for navigation events
|
|
200
|
-
switch (options.
|
|
233
|
+
switch (options.type) {
|
|
201
234
|
case 'hash':
|
|
202
235
|
window.addEventListener('hashchange', () => {
|
|
203
236
|
// Only navigate if target still exists
|
|
@@ -223,7 +256,7 @@ export function Router(options: RouterOptions) {
|
|
|
223
256
|
}
|
|
224
257
|
|
|
225
258
|
function get_path(): string {
|
|
226
|
-
switch (options.
|
|
259
|
+
switch (options.type) {
|
|
227
260
|
case 'hash':
|
|
228
261
|
return window.location.hash.slice(1);
|
|
229
262
|
case 'pushstate':
|
|
@@ -245,10 +278,45 @@ export function Router(options: RouterOptions) {
|
|
|
245
278
|
|
|
246
279
|
let newPageElement: HTMLElement | null = null;
|
|
247
280
|
let transition: PageTransition | undefined;
|
|
281
|
+
let guards: Guard<any> | Guard<any>[] | undefined;
|
|
248
282
|
|
|
249
283
|
// Home
|
|
250
284
|
if ((path.trim() === '' || path === '/') && home) {
|
|
285
|
+
// Find home route to get guards
|
|
286
|
+
const homeRoute = routes.find(r => r.route.match('/'));
|
|
287
|
+
guards = homeRoute?.guards;
|
|
288
|
+
|
|
289
|
+
// Check guards before creating element
|
|
290
|
+
if (guards) {
|
|
291
|
+
const guardsArray = Array.isArray(guards) ? guards : [guards];
|
|
292
|
+
for (const guard of guardsArray) {
|
|
293
|
+
const allowed = await guard(context, {}); // No params for home route
|
|
294
|
+
if (!allowed) {
|
|
295
|
+
// Render 403 page
|
|
296
|
+
if (_403) {
|
|
297
|
+
newPageElement = document.createElement(_403);
|
|
298
|
+
// Store context on 403 page too
|
|
299
|
+
(newPageElement as any)[ROUTER_CONTEXT] = context;
|
|
300
|
+
} else {
|
|
301
|
+
const div = document.createElement('div');
|
|
302
|
+
div.className = 'default-403';
|
|
303
|
+
div.innerHTML = '<h1>403</h1><p>Unauthorized</p>';
|
|
304
|
+
newPageElement = div;
|
|
305
|
+
}
|
|
306
|
+
// Don't perform transition for 403
|
|
307
|
+
target.innerHTML = '';
|
|
308
|
+
if (newPageElement) {
|
|
309
|
+
target.appendChild(newPageElement);
|
|
310
|
+
}
|
|
311
|
+
currentPageElement = newPageElement;
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
251
317
|
newPageElement = document.createElement(home);
|
|
318
|
+
// Store context on the page element
|
|
319
|
+
(newPageElement as any)[ROUTER_CONTEXT] = context;
|
|
252
320
|
const constructor = customElements.get(home);
|
|
253
321
|
transition = (constructor as any)?.__transition;
|
|
254
322
|
} else {
|
|
@@ -259,7 +327,37 @@ export function Router(options: RouterOptions) {
|
|
|
259
327
|
const is_match = params !== false;
|
|
260
328
|
|
|
261
329
|
if (is_match) {
|
|
330
|
+
// Check guards before creating element
|
|
331
|
+
if (route.guards) {
|
|
332
|
+
const guardsArray = Array.isArray(route.guards) ? route.guards : [route.guards];
|
|
333
|
+
for (const guard of guardsArray) {
|
|
334
|
+
const allowed = await guard(context, params as RouteParams);
|
|
335
|
+
if (!allowed) {
|
|
336
|
+
// Render 403 page
|
|
337
|
+
if (_403) {
|
|
338
|
+
newPageElement = document.createElement(_403);
|
|
339
|
+
// Store context on 403 page too
|
|
340
|
+
(newPageElement as any)[ROUTER_CONTEXT] = context;
|
|
341
|
+
} else {
|
|
342
|
+
const div = document.createElement('div');
|
|
343
|
+
div.className = 'default-403';
|
|
344
|
+
div.innerHTML = '<h1>403</h1><p>Unauthorized</p>';
|
|
345
|
+
newPageElement = div;
|
|
346
|
+
}
|
|
347
|
+
// Don't perform transition for 403
|
|
348
|
+
target.innerHTML = '';
|
|
349
|
+
if (newPageElement) {
|
|
350
|
+
target.appendChild(newPageElement);
|
|
351
|
+
}
|
|
352
|
+
currentPageElement = newPageElement;
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
262
358
|
newPageElement = document.createElement(route.tag);
|
|
359
|
+
// Store context on the page element
|
|
360
|
+
(newPageElement as any)[ROUTER_CONTEXT] = context;
|
|
263
361
|
Object.keys(params).forEach(key => newPageElement!.setAttribute(key, params[key]));
|
|
264
362
|
transition = route.transition;
|
|
265
363
|
break;
|
|
@@ -271,6 +369,8 @@ export function Router(options: RouterOptions) {
|
|
|
271
369
|
if (!newPageElement) {
|
|
272
370
|
if (_404) {
|
|
273
371
|
newPageElement = document.createElement(_404);
|
|
372
|
+
// Store context on 404 page too
|
|
373
|
+
(newPageElement as any)[ROUTER_CONTEXT] = context;
|
|
274
374
|
const constructor = customElements.get(_404);
|
|
275
375
|
transition = (constructor as any)?.__transition;
|
|
276
376
|
} else {
|
|
@@ -382,5 +482,11 @@ export function Router(options: RouterOptions) {
|
|
|
382
482
|
containerStyle.position = originalPosition;
|
|
383
483
|
}
|
|
384
484
|
|
|
385
|
-
return {
|
|
485
|
+
return {
|
|
486
|
+
page,
|
|
487
|
+
initialize,
|
|
488
|
+
navigate,
|
|
489
|
+
register,
|
|
490
|
+
context,
|
|
491
|
+
};
|
|
386
492
|
}
|
package/src/symbols.ts
CHANGED
|
@@ -30,4 +30,8 @@ export const PROPERTIES = getSymbol('properties');
|
|
|
30
30
|
export const PROPERTY_VALUES = getSymbol('property-values');
|
|
31
31
|
export const PROPERTIES_INITIALIZED = getSymbol('properties-initialized');
|
|
32
32
|
export const PROPERTY_WATCHERS = getSymbol('property-watchers');
|
|
33
|
-
export const EXPLICITLY_SET_PROPERTIES = getSymbol('explicitly-set-properties');
|
|
33
|
+
export const EXPLICITLY_SET_PROPERTIES = getSymbol('explicitly-set-properties');
|
|
34
|
+
|
|
35
|
+
// Router context symbol
|
|
36
|
+
export const ROUTER_CONTEXT = getSymbol('router-context');
|
|
37
|
+
export const CONTEXT_REQUEST_HANDLER = getSymbol('context-request-handler');
|