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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # Snice
2
2
 
3
- A lightweight TypeScript framework for building web components with decorators and routing.
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 eliminates the complexity of reactive systems, virtual DOM diffing, and unexpected re-renders. You write straightforward code that does exactly what you tell it to do.
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
- ### Component Decorators
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`** - Sets up routing and navigation between different views
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({ reflect: true })
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
- @property({ type: Boolean })
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 class="toggle-button">
183
- <span class="theme-icon">🌞</span>
176
+ <button>
177
+ <span class="icon">🌞</span>
184
178
  </button>
185
179
  `;
186
180
  }
187
181
 
188
182
  @watch('theme')
189
- onThemeChange(oldTheme: string, newTheme: string, propertyName: string) {
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 = newTheme === 'dark' ? '🌙' : '🌞';
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
- @watch('animated')
212
- onAnimatedChange(oldValue: boolean, newValue: boolean, propertyName: string) {
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(oldValue: number, newValue: number, propertyName: string) {
207
+ updateDimensions(_old: number, _new: number, _name: string) {
238
208
  // Called when any of these properties change
239
- console.log(`${propertyName} changed from ${oldValue} to ${newValue}`);
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(oldValue: any, newValue: any, propertyName: string) {
249
- console.log(`Property ${propertyName} changed from ${oldValue} to ${newValue}`);
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?.classList.toggle('on', this.isOn);
354
-
355
- // Return value becomes event detail
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 id="123" on UserPage
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
- Request/response between elements and controllers:
550
+ Bidirectional communication between elements and controllers:
497
551
 
498
552
  ```typescript
499
- // --- components/user-card.ts
500
- @element('user-card')
501
- class UserCard extends HTMLElement {
502
- @channel('get-data')
503
- async *fetchUserData() {
504
- // Yield sends request, await waits for response
505
- const user = await (yield { id: 123 });
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 loadUser() {
510
- const user = await this.fetchUserData();
511
- console.log(user); // { name: 'Alice' }
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
- element: HTMLElement | null = null;
519
-
520
- async attach(element: HTMLElement) {}
521
- async detach(element: HTMLElement) {}
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
- ## Complete Example
582
+ ## Router Context
532
583
 
533
- Here's how the pieces work together - a generic display component filled by a controller:
584
+ Access router context in page components, nested elements, and controllers using the `@context` decorator.
534
585
 
535
- ```typescript
536
- import { element, controller, query } from 'snice';
586
+ ### ⚠️ Important Warning About Global State
537
587
 
538
- // Generic card component - purely visual
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
- html() {
551
- return `
552
- <div class="card">
553
- <h2 class="title">Loading...</h2>
554
- <div class="content">
555
- <div class="skeleton"></div>
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
- css() {
563
- return `
564
- .card {
565
- border: 1px solid #ddd;
566
- border-radius: 8px;
567
- padding: 20px;
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
- // Methods the controller can call to update the display
596
- setTitle(title: string) {
597
- if (this.titleElement) {
598
- this.titleElement.textContent = title;
599
- }
600
- }
604
+ // Controlled mutation through methods
605
+ setUser(user: User) {...}
601
606
 
602
- setContent(html: string) {
603
- if (this.contentElement) {
604
- this.contentElement.innerHTML = html;
605
- }
606
- }
607
+ // Read-only access
608
+ getUser() {...}
607
609
 
608
- setFooter(text: string) {
609
- if (this.footerElement) {
610
- this.footerElement.textContent = text;
611
- }
612
- }
610
+ isAuthenticated() {...}
613
611
  }
614
612
 
615
- // Controller that fetches and populates data
616
- @controller('weather-controller')
617
- class WeatherController {
618
- element: HTMLElement | null = null;
619
-
620
- async attach(element: HTMLElement) {
621
-
622
- // Simulate fetching weather data
623
- await new Promise(resolve => setTimeout(resolve, 1000));
624
-
625
- const weatherData = {
626
- location: 'San Francisco',
627
- temp: '72°F',
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
- async detach(element: HTMLElement) {
643
- // Cleanup if needed
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
- Use the same card with different controllers:
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
- ### Component Decorators
643
+ Nested elements within pages can also access context through event bubbling:
663
644
 
664
- | Decorator | Purpose | Example |
665
- |-----------|---------|---------|
666
- | `@element(tagName)` | Defines a custom HTML element | `@element('my-button')` |
667
- | `@controller(name)` | Creates a data controller | `@controller('user-controller')` |
668
- | `@page(options)` | Defines a routable page component | `@page({ tag: 'home-page', routes: ['/'] })` |
669
-
670
- ### Property Decorators
671
-
672
- | Decorator | Purpose | Example |
673
- |-----------|---------|---------|
674
- | `@property(options)` | Declares a property that can reflect to attributes | `@property({ type: Boolean, reflect: true })` |
675
- | `@query(selector)` | Queries a single element from shadow DOM | `@query('.button')` |
676
- | `@queryAll(selector)` | Queries multiple elements from shadow DOM | `@queryAll('input[type="checkbox"]')` |
677
- | `@watch(...propertyNames)` | Watches properties for changes and calls the method | `@watch('width', 'height')` or `@watch('*')` |
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
- ### Event Decorators
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
- | Decorator | Purpose | Example |
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
- ### Property Options
675
+ Controllers attached to page elements automatically acquire context:
688
676
 
689
677
  ```typescript
690
- interface PropertyOptions {
691
- type?: typeof String | typeof Number | typeof Boolean | typeof Array | typeof Object; // Type converter
692
- reflect?: boolean; // Reflect property to attribute
693
- attribute?: string | boolean; // Custom attribute name or false to disable
694
- converter?: PropertyConverter; // Custom converter
695
- hasChanged?: (value: any, oldValue: any) => boolean; // Custom change detector
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
- interface PropertyConverter {
699
- fromAttribute?(value: string | null, type?: any): any;
700
- toAttribute?(value: any, type?: any): string | null;
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
- ### Dispatch Options
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
- ```typescript
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
+ }
@@ -2,7 +2,7 @@ import { Router } from 'snice';
2
2
 
3
3
  const { page, initialize, navigate } = Router({
4
4
  target: '#app',
5
- routing_type: 'hash',
5
+ type: 'hash',
6
6
  transition: {
7
7
  mode: 'simultaneous',
8
8
  outDuration: 200,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "snice",
3
- "version": "1.4.0",
3
+ "version": "1.6.0",
4
4
  "type": "module",
5
5
  "description": "A TypeScript library",
6
6
  "main": "src/index.ts",
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:${channelName}`;
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:${handler.channelName}`;
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
- export function element(tagName: string) {
6
- return function (constructor: any) {
7
- // Mark as element class for channel decorator detection
8
- (constructor.prototype as any)[IS_ELEMENT_CLASS] = true;
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
- originalConnectedCallback?.call(this);
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
- originalDisconnectedCallback?.call(this);
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 { setupEventHandlers, cleanupEventHandlers } from './events';
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
- routing_type: 'hash' | 'pushstate';
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 <T extends { new(...args: any[]): HTMLElement }>(constructor: T) {
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 original connectedCallback first to allow property initialization
112
- originalConnectedCallback?.call(this);
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
- // Add HTML first (maintaining original order)
123
- if (this.html) {
124
- const htmlContent = this.html();
125
- if (htmlContent !== undefined) {
126
- shadowContent += htmlContent;
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
- // Add CSS after HTML (maintaining original order)
131
- if (this.css) {
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
- originalDisconnectedCallback?.call(this);
151
- // Cleanup @on event handlers
152
- cleanupEventHandlers(this);
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.routing_type) {
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.routing_type) {
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 { page, initialize, navigate, register };
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');