snice 1.10.0 → 1.11.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
@@ -23,6 +23,68 @@ Snice takes an **imperative approach** to web components. Unlike reactive framew
23
23
 
24
24
  This approach gives you direct control over DOM updates without hidden complexity or automatic re-renders.
25
25
 
26
+ ## The Snice Way: Elements + Controllers
27
+
28
+ Snice separates UI from data: **elements handle UI, controllers handle behavior and data**.
29
+
30
+ ```typescript
31
+ import { element, controller, property, query } from 'snice';
32
+
33
+ export interface IUserCard extends HTMLElement {
34
+ userId: string;
35
+ showUser(user: any): void;
36
+ }
37
+
38
+ // Element: Just UI
39
+ @element('user-card')
40
+ class UserCard extends HTMLElement implements IUserCard {
41
+ @property({ attribute: 'user-id' })
42
+ userId = '';
43
+
44
+ @query('h3')
45
+ nameElement!: HTMLHeadingElement;
46
+
47
+ @query('p')
48
+ emailElement!: HTMLParagraphElement;
49
+
50
+ html() {
51
+ return `
52
+ <div class="card">
53
+ <h3>Loading...</h3>
54
+ <p>Please wait...</p>
55
+ </div>
56
+ `;
57
+ }
58
+
59
+ showUser(user: any) {
60
+ this.nameElement.textContent = user.name;
61
+ this.emailElement.textContent = user.email;
62
+ }
63
+ }
64
+
65
+ // Controller: Data and behavior
66
+ @controller('user-loader')
67
+ class UserLoaderController {
68
+ element!: IUserCard;
69
+
70
+ async attach(element: IUserCard) {
71
+ const response = await fetch(`/api/users/${element.userId}`);
72
+ const user = await response.json();
73
+
74
+ element.showUser(user);
75
+ }
76
+
77
+ async detach(element: IUserCard) { /* Cleanup */ }
78
+ }
79
+ ```
80
+
81
+ Connect them in HTML:
82
+ ```html
83
+ <user-card user-id="123" controller="user-loader"></user-card>
84
+ ```
85
+
86
+ That's it. Element renders UI, controller fetches data, they communicate through method calls, events, and request/response channels.
87
+
26
88
  ## Core Concepts
27
89
 
28
90
  Snice provides a clear separation of concerns through decorators:
@@ -80,10 +142,10 @@ class CounterDisplay extends HTMLElement {
80
142
  count = 0;
81
143
 
82
144
  @query('.count')
83
- countElement?: HTMLElement;
145
+ countElement!: HTMLSpanElement;
84
146
 
85
147
  @query('.status')
86
- statusElement?: HTMLElement;
148
+ statusElement!: HTMLSpanElement;
87
149
 
88
150
  html() {
89
151
  // Renders ONCE - no automatic re-rendering
@@ -98,15 +160,11 @@ class CounterDisplay extends HTMLElement {
98
160
  // Imperative update methods - YOU control when updates happen
99
161
  setCount(newCount: number) {
100
162
  this.count = newCount;
101
- if (this.countElement) {
102
- this.countElement.textContent = String(newCount);
103
- }
163
+ this.countElement.textContent = String(newCount);
104
164
  }
105
165
 
106
166
  setStatus(status: string) {
107
- if (this.statusElement) {
108
- this.statusElement.textContent = status;
109
- }
167
+ this.statusElement.textContent = status;
110
168
  }
111
169
 
112
170
  increment() {
@@ -191,7 +249,7 @@ class ThemeToggle extends HTMLElement {
191
249
  theme: 'light' | 'dark' = 'light';
192
250
 
193
251
  @query('.icon')
194
- icon?: HTMLElement;
252
+ icon!: HTMLSpanElement;
195
253
 
196
254
  html() {
197
255
  return `
@@ -203,9 +261,7 @@ class ThemeToggle extends HTMLElement {
203
261
 
204
262
  @watch('theme')
205
263
  updateTheme(oldValue: string, newValue: string) {
206
- if (this.icon) {
207
- this.icon.textContent = newValue === 'dark' ? '🌙' : '🌞';
208
- }
264
+ this.icon.textContent = newValue === 'dark' ? '🌙' : '🌞';
209
265
  }
210
266
 
211
267
  @on('click', 'button')
@@ -253,14 +309,14 @@ import { element, query } from 'snice';
253
309
  @element('my-form')
254
310
  class MyForm extends HTMLElement {
255
311
  @query('input')
256
- input?: HTMLInputElement;
312
+ input!: HTMLInputElement;
257
313
 
258
314
  html() {
259
315
  return `<input type="text" />`;
260
316
  }
261
317
 
262
318
  getValue() {
263
- return this.input?.value;
319
+ return this.input.value;
264
320
  }
265
321
  }
266
322
  ```
@@ -273,7 +329,7 @@ import { element, queryAll } from 'snice';
273
329
  @element('checkbox-group')
274
330
  class CheckboxGroup extends HTMLElement {
275
331
  @queryAll('input[type="checkbox"]')
276
- checkboxes?: NodeListOf<HTMLInputElement>;
332
+ checkboxes!: NodeListOf<HTMLInputElement>;
277
333
 
278
334
  html() {
279
335
  return `
@@ -284,7 +340,6 @@ class CheckboxGroup extends HTMLElement {
284
340
  }
285
341
 
286
342
  getSelectedValues() {
287
- if (!this.checkboxes) return [];
288
343
  return Array.from(this.checkboxes)
289
344
  .filter(cb => cb.checked)
290
345
  .map(cb => cb.value);
@@ -342,7 +397,7 @@ class ToggleSwitch extends HTMLElement {
342
397
  private isOn = false;
343
398
 
344
399
  @query('.toggle')
345
- toggleButton?: HTMLElement;
400
+ toggleButton!: HTMLElement;
346
401
 
347
402
  html() {
348
403
  return `<button class="toggle">OFF</button>`;
@@ -352,9 +407,7 @@ class ToggleSwitch extends HTMLElement {
352
407
  @dispatch('toggled')
353
408
  toggle() {
354
409
  this.isOn = !this.isOn;
355
- if (this.toggleButton) {
356
- this.toggleButton.textContent = this.isOn ? 'ON' : 'OFF';
357
- }
410
+ this.toggleButton.textContent = this.isOn ? 'ON' : 'OFF';
358
411
  return { on: this.isOn };
359
412
  }
360
413
  }
@@ -543,19 +596,21 @@ Controllers handle server communication separately from visual components:
543
596
  ```typescript
544
597
  import { controller, element } from 'snice';
545
598
 
599
+ interface IUserElement extends HTMLElement {
600
+ setUsers(users: any[]): void;
601
+ }
602
+
546
603
  @controller('user-controller')
547
604
  class UserController {
548
- element: HTMLElement | null = null;
605
+ element!: IUserElement;
549
606
 
550
- async attach(element: HTMLElement) {
607
+ async attach(element: IUserElement) {
551
608
  const response = await fetch('/api/users');
552
609
  const users = await response.json();
553
- (element as any).setUsers(users);
610
+ element.setUsers(users);
554
611
  }
555
612
 
556
- async detach(element: HTMLElement) {
557
- // Cleanup
558
- }
613
+ async detach(element: IUserElement) { /* Cleanup */ }
559
614
  }
560
615
 
561
616
  @element('user-list')
@@ -622,6 +677,70 @@ class UserController {
622
677
  }
623
678
  ```
624
679
 
680
+ ## Layouts
681
+
682
+ Wrap your pages in shared layout components for consistent navigation, headers, and footers across your application.
683
+
684
+ ### Basic Layout Usage
685
+
686
+ ```typescript
687
+ import { Router, layout, page } from 'snice';
688
+
689
+ // Create a layout component
690
+ @layout('app-shell')
691
+ class AppShell extends HTMLElement {
692
+ html() {
693
+ return `
694
+ <header>
695
+ <nav>
696
+ <a href="#/">Home</a>
697
+ <a href="#/about">About</a>
698
+ </nav>
699
+ </header>
700
+ <main>
701
+ <slot name="page"></slot>
702
+ </main>
703
+ <footer>© 2024 My App</footer>
704
+ `;
705
+ }
706
+ }
707
+
708
+ // Configure router with default layout
709
+ const router = Router({
710
+ target: '#app',
711
+ type: 'hash',
712
+ layout: 'app-shell' // All pages use this layout by default
713
+ });
714
+
715
+ const { page, initialize } = router;
716
+
717
+ // Pages automatically render inside the layout
718
+ @page({ tag: 'home-page', routes: ['/'] })
719
+ class HomePage extends HTMLElement {
720
+ html() {
721
+ return `<h1>Home Content</h1>`;
722
+ }
723
+ }
724
+
725
+ // Override layout per page
726
+ @page({ tag: 'full-page', routes: ['/fullscreen'], layout: false })
727
+ class FullPage extends HTMLElement {
728
+ html() {
729
+ return `<div>No layout wrapper</div>`;
730
+ }
731
+ }
732
+
733
+ initialize();
734
+ ```
735
+
736
+ ### Layout Features
737
+
738
+ - **Shared wrapper**: Layout components wrap page content using `<slot name="page"></slot>`
739
+ - **Default layouts**: Set `layout: 'component-name'` in router options
740
+ - **Per-page override**: Use `layout: 'other-layout'` or `layout: false` in page options
741
+ - **Smooth transitions**: Layout persists during page transitions for better UX
742
+ - **Nested layouts**: Layouts can contain other layouts for complex structures
743
+
625
744
  ## Router Context
626
745
 
627
746
  Access router context in page components, nested elements, and controllers using the `@context` decorator.
@@ -683,7 +802,7 @@ class ProfilePage extends HTMLElement {
683
802
 
684
803
  ### Context in Nested Elements
685
804
 
686
- Nested elements within pages can also access context through event bubbling:
805
+ Nested elements within pages can also access context:
687
806
 
688
807
  ```typescript
689
808
  // This element can be used inside any page
@@ -720,7 +839,7 @@ Controllers attached to page elements automatically acquire context:
720
839
  ```typescript
721
840
  @controller('nav-controller')
722
841
  class NavController {
723
- element: HTMLElement | null = null;
842
+ element!: HTMLElement;
724
843
 
725
844
  @context()
726
845
  ctx?: AppContext;
@@ -732,9 +851,7 @@ class NavController {
732
851
  }
733
852
  }
734
853
 
735
- detach(element: HTMLElement) {
736
- // Cleanup if needed
737
- }
854
+ detach(element: HTMLElement) { /* Cleanup */ }
738
855
  }
739
856
 
740
857
  @page({ tag: 'admin-page', routes: ['/admin'] })
@@ -912,7 +1029,6 @@ The `@part` decorator is ideal when you have components with multiple independen
912
1029
  - [Request/Response API](./docs/request-response.md) - Bidirectional communication between elements and controllers
913
1030
  - [Routing API](./docs/routing.md) - Single-page application routing with transitions
914
1031
  - [Observe API](./docs/observe.md) - Lifecycle-managed observers for external changes
915
- - [Migration Guide](./docs/migration-guide.md) - Migrating from React, Vue, Angular, and other frameworks
916
1032
 
917
1033
  ## License
918
1034
 
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "snice",
3
- "version": "1.10.0",
3
+ "version": "1.11.0",
4
4
  "type": "module",
5
- "description": "A TypeScript library",
5
+ "description": "Imperative TypeScript framework for building vanilla web components with decorators, routing, and controllers. No virtual DOM, no build complexity.",
6
6
  "main": "src/index.ts",
7
7
  "module": "src/index.ts",
8
8
  "types": "src/index.ts",
@@ -16,6 +16,40 @@
16
16
  "!src/**/*.test.ts",
17
17
  "!src/**/*.spec.ts"
18
18
  ],
19
+ "keywords": [
20
+ "web components",
21
+ "typescript",
22
+ "framework",
23
+ "decorators",
24
+ "custom elements",
25
+ "vanilla js",
26
+ "frontend",
27
+ "spa",
28
+ "routing",
29
+ "imperative",
30
+ "no virtual dom",
31
+ "lightweight",
32
+ "minimal",
33
+ "controllers",
34
+ "mvc",
35
+ "shadow dom",
36
+ "lit alternative",
37
+ "stencil alternative",
38
+ "web standards"
39
+ ],
40
+ "author": "hedzer",
41
+ "license": "MIT",
42
+ "homepage": "https://gitlab.com/Hedzer/snice#readme",
43
+ "repository": {
44
+ "type": "git",
45
+ "url": "git@gitlab.com:Hedzer/snice.git"
46
+ },
47
+ "bugs": {
48
+ "url": "https://gitlab.com/Hedzer/snice/-/issues"
49
+ },
50
+ "engines": {
51
+ "node": ">=18.0.0"
52
+ },
19
53
  "publishConfig": {
20
54
  "access": "public"
21
55
  },
package/src/element.ts CHANGED
@@ -398,6 +398,13 @@ export function element(tagName: string) {
398
398
  };
399
399
  }
400
400
 
401
+ export function layout(tagName: string) {
402
+ return function (constructor: any) {
403
+ applyElementFunctionality(constructor);
404
+ customElements.define(tagName, constructor);
405
+ };
406
+ }
407
+
401
408
  export function property(options?: PropertyOptions) {
402
409
  return function (target: any, propertyKey: string) {
403
410
  const constructor = target.constructor;
package/src/index.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { element, property, query, queryAll, watch, context, applyElementFunctionality, ready, dispose, part, SimpleArray } from './element';
1
+ export { element, layout, property, query, queryAll, watch, context, applyElementFunctionality, ready, dispose, part, SimpleArray } 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';
package/src/router.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import Route from 'route-parser';
2
2
  import { applyElementFunctionality } from './element';
3
- import { ROUTER_CONTEXT, CONTEXT_REQUEST_HANDLER, PAGE_TRANSITION } from './symbols';
3
+ import { ROUTER_CONTEXT, CONTEXT_REQUEST_HANDLER, PAGE_TRANSITION, CREATED_AT } from './symbols';
4
4
  import { Transition, performTransition as performTransitionUtil } from './transitions';
5
5
 
6
6
  /**
@@ -8,6 +8,15 @@ import { Transition, performTransition as performTransitionUtil } from './transi
8
8
  */
9
9
  export type RouteParams = Record<string, string>;
10
10
 
11
+ /**
12
+ * Possible outcomes from route resolution
13
+ */
14
+ enum RouteResult {
15
+ SUCCESS = 'success',
16
+ GUARDS_FAILED = 'guards-failed',
17
+ NOT_FOUND = 'not-found'
18
+ }
19
+
11
20
  /**
12
21
  * Guard function that determines if navigation is allowed
13
22
  * @param context The application context
@@ -46,6 +55,11 @@ export interface RouterOptions {
46
55
  * Optional context object passed to guard functions
47
56
  */
48
57
  context?: any;
58
+
59
+ /**
60
+ * Default layout element tag name for all pages
61
+ */
62
+ layout?: string;
49
63
  }
50
64
 
51
65
  export interface PageOptions {
@@ -72,6 +86,12 @@ export interface PageOptions {
72
86
  * Can be a single guard or an array of guards (all must pass).
73
87
  */
74
88
  guards?: Guard<any> | Guard<any>[];
89
+
90
+ /**
91
+ * Layout element tag name for this page.
92
+ * Use false to explicitly disable layout for this page.
93
+ */
94
+ layout?: string | false;
75
95
  }
76
96
 
77
97
  /**
@@ -91,15 +111,34 @@ export interface RouterInstance {
91
111
  * @returns An object containing the router's API methods.
92
112
  */
93
113
  export function Router(options: RouterOptions): RouterInstance {
94
- const routes: { route: Route, tag: string, transition?: Transition, guards?: Guard<any> | Guard<any>[] }[] = [];
114
+ const routes: { route: Route, tag: string, transition?: Transition, guards?: Guard<any> | Guard<any>[], layout?: string | false }[] = [];
95
115
  let is_sorted = false;
96
116
 
97
117
  let _404: string; // the 404 page
98
118
  let _403: string; // the 403 forbidden page
99
119
  let home: string; // the home page
100
- let currentPageElement: HTMLElement | null = null; // Track current page for transitions
120
+ let currentLayoutName: string | null = null; // Track current layout name
121
+ let currentLayoutTimestamp: number | null = null; // Track current layout timestamp
101
122
  const context = options.context || {}; // Store context for guards
102
123
 
124
+ function getCurrentLayoutElement(target: Element): HTMLElement | null {
125
+ const noCurrentLayout = !currentLayoutName || !currentLayoutTimestamp;
126
+ if (noCurrentLayout) {
127
+ return null;
128
+ }
129
+
130
+ const layoutElements = target.querySelectorAll(currentLayoutName!) as NodeListOf<HTMLElement>;
131
+
132
+ for (const element of layoutElements) {
133
+ const hasTimestamp = (element as any)[CREATED_AT] === currentLayoutTimestamp;
134
+ if (hasTimestamp) {
135
+ return element;
136
+ }
137
+ }
138
+
139
+ return null;
140
+ }
141
+
103
142
  /**
104
143
  * Decorator function for defining a page with associated routes.
105
144
  * @param {PageOptions} pageOptions - The page configuration options.
@@ -153,8 +192,8 @@ export function Router(options: RouterOptions): RouterInstance {
153
192
  // Define the custom element
154
193
  customElements.define(pageOptions.tag, constructor);
155
194
 
156
- // Register the routes with guards
157
- pageOptions.routes.forEach(route => register(route, pageOptions.tag, pageOptions.transition, pageOptions.guards));
195
+ // Register the routes with guards and layout
196
+ pageOptions.routes.forEach(route => register(route, pageOptions.tag, pageOptions.transition, pageOptions.guards, pageOptions.layout));
158
197
  }
159
198
  }
160
199
 
@@ -165,8 +204,8 @@ export function Router(options: RouterOptions): RouterInstance {
165
204
  * @example
166
205
  * register('/custom-route', 'custom-element');
167
206
  */
168
- function register(route: string, tag: string, transition?: Transition, guards?: Guard<any> | Guard<any>[]): void {
169
- routes.push({ route: new Route(route), tag, transition, guards });
207
+ function register(route: string, tag: string, transition?: Transition, guards?: Guard<any> | Guard<any>[], layout?: string | false): void {
208
+ routes.push({ route: new Route(route), tag, transition, guards, layout });
170
209
  is_sorted = false;
171
210
 
172
211
  if (route === '/404') {
@@ -182,49 +221,59 @@ export function Router(options: RouterOptions): RouterInstance {
182
221
  }
183
222
  }
184
223
 
224
+ function setupEventListeners(): void {
225
+ const isHashType = options.type === 'hash';
226
+ const isPushStateType = options.type === 'pushstate';
227
+
228
+ if (isHashType) {
229
+ window.addEventListener('hashchange', () => {
230
+ const targetExists = !!document.querySelector(options.target);
231
+ if (!targetExists) {
232
+ return;
233
+ }
234
+
235
+ const path = getPath();
236
+ navigate(path);
237
+ });
238
+ }
239
+
240
+ if (isPushStateType) {
241
+ window.addEventListener('popstate', () => {
242
+ const targetExists = !!document.querySelector(options.target);
243
+ if (!targetExists) {
244
+ return;
245
+ }
246
+
247
+ const path = getPath();
248
+ navigate(path);
249
+ });
250
+ }
251
+ }
252
+
185
253
  /**
186
254
  * Initializes the router and sets up navigation event listeners.
187
255
  * @example
188
256
  * initialize();
189
257
  */
190
258
  function initialize(): void {
191
- // Check if target exists before initializing
192
- if (!document.querySelector(options.target)) {
259
+ const targetExists = !!document.querySelector(options.target);
260
+ if (!targetExists) {
193
261
  throw new Error(`Target element not found: ${options.target}`);
194
262
  }
195
263
 
196
- if (!is_sorted) {
264
+ const needsSorting = !is_sorted;
265
+ if (needsSorting) {
197
266
  routes.sort((a: any, b: any) => b.route.spec.length - a.route.spec.length);
198
267
  is_sorted = true;
199
268
  }
200
269
 
201
- // Listen for navigation events
202
- switch (options.type) {
203
- case 'hash':
204
- window.addEventListener('hashchange', () => {
205
- // Only navigate if target still exists
206
- if (document.querySelector(options.target)) {
207
- const path = get_path();
208
- navigate(path);
209
- }
210
- });
211
- break;
212
- case 'pushstate':
213
- window.addEventListener('popstate', () => {
214
- // Only navigate if target still exists
215
- if (document.querySelector(options.target)) {
216
- const path = get_path();
217
- navigate(path);
218
- }
219
- });
220
- break;
221
- }
270
+ setupEventListeners();
222
271
 
223
- const path = get_path();
272
+ const path = getPath();
224
273
  navigate(path);
225
274
  }
226
275
 
227
- function get_path(): string {
276
+ function getPath(): string {
228
277
  switch (options.type) {
229
278
  case 'hash':
230
279
  return window.location.hash.slice(1);
@@ -233,6 +282,181 @@ export function Router(options: RouterOptions): RouterInstance {
233
282
  }
234
283
  }
235
284
 
285
+ async function renderForbiddenPage(target: Element): Promise<void> {
286
+ let newPageElement: HTMLElement;
287
+ const has403Page = !!_403;
288
+
289
+ if (has403Page) {
290
+ newPageElement = document.createElement(_403);
291
+ (newPageElement as any)[ROUTER_CONTEXT] = context;
292
+ }
293
+ if (!has403Page) {
294
+ const div = document.createElement('div');
295
+ div.className = 'default-403';
296
+ div.innerHTML = /*html*/`<h1>403</h1><p>Unauthorized</p>`;
297
+ newPageElement = div;
298
+ }
299
+
300
+ target.innerHTML = '';
301
+ target.appendChild(newPageElement!);
302
+ currentLayoutName = null;
303
+ currentLayoutTimestamp = null;
304
+ }
305
+
306
+ async function checkGuards(guards: Guard<any> | Guard<any>[] | undefined, params: RouteParams, target: Element): Promise<boolean> {
307
+ const hasGuards = !!guards;
308
+ if (!hasGuards) {
309
+ return true;
310
+ }
311
+
312
+ const guardsArray = Array.isArray(guards) ? guards : [guards];
313
+ for (const guard of guardsArray) {
314
+ const allowed = await guard(context, params);
315
+ if (!allowed) {
316
+ await renderForbiddenPage(target);
317
+ return false;
318
+ }
319
+ }
320
+ return true;
321
+ }
322
+
323
+ function createHomeElement(): { element: HTMLElement; transition?: Transition; layout?: string | false } {
324
+ const newPageElement = document.createElement(home);
325
+ (newPageElement as any)[ROUTER_CONTEXT] = context;
326
+ const constructor = customElements.get(home);
327
+ const transition = (constructor as any)?.[PAGE_TRANSITION];
328
+
329
+ const homeRoute = routes.find(r => r.route.match('/'));
330
+ return { element: newPageElement, transition, layout: homeRoute?.layout };
331
+ }
332
+
333
+ function create404Element(): { element: HTMLElement; transition?: Transition; layout?: string | false } {
334
+ const has404Page = !!_404;
335
+
336
+ if (has404Page) {
337
+ const newPageElement = document.createElement(_404);
338
+ (newPageElement as any)[ROUTER_CONTEXT] = context;
339
+ const constructor = customElements.get(_404);
340
+ const transition = (constructor as any)?.[PAGE_TRANSITION];
341
+ return { element: newPageElement, transition, layout: undefined };
342
+ }
343
+
344
+ const div = document.createElement('div');
345
+ div.className = 'default-404';
346
+ div.innerHTML = /*html*/`<h1>404</h1><p>Page not found</p>`;
347
+ return { element: div, transition: undefined, layout: undefined };
348
+ }
349
+
350
+ async function resolveRoute(path: string, target: Element): Promise<{ result: RouteResult; element?: HTMLElement; transition?: Transition; layout?: string | false }> {
351
+ for (const route of routes) {
352
+ const params = route.route.match(path);
353
+ const isMatch = params !== false;
354
+ if (!isMatch) {
355
+ continue;
356
+ }
357
+
358
+ const guardsAllowed = await checkGuards(route.guards, params as RouteParams, target);
359
+ if (!guardsAllowed) {
360
+ return { result: RouteResult.GUARDS_FAILED };
361
+ }
362
+
363
+ const newPageElement = document.createElement(route.tag);
364
+ (newPageElement as any)[ROUTER_CONTEXT] = context;
365
+ const routeParams = params as RouteParams;
366
+ Object.keys(routeParams).forEach(key => newPageElement.setAttribute(key, routeParams[key]));
367
+
368
+ return { result: RouteResult.SUCCESS, element: newPageElement, transition: route.transition, layout: route.layout };
369
+ }
370
+
371
+ return { result: RouteResult.NOT_FOUND };
372
+ }
373
+
374
+ function determineLayout(pageLayout: string | false | undefined): string | null {
375
+ const isExplicitlyNoLayout = pageLayout === false;
376
+ if (isExplicitlyNoLayout) {
377
+ return null;
378
+ }
379
+
380
+ const hasPageLayout = !!pageLayout;
381
+ if (hasPageLayout) {
382
+ return pageLayout;
383
+ }
384
+
385
+ const hasRouterLayout = !!options.layout;
386
+ if (hasRouterLayout) {
387
+ return options.layout!;
388
+ }
389
+
390
+ return null;
391
+ }
392
+
393
+ function setupLayout(layoutToUse: string | null): { element: HTMLElement | null; needsNewLayout: boolean } {
394
+ const needsNewLayout = layoutToUse !== currentLayoutName;
395
+ if (!needsNewLayout) {
396
+ return { element: null, needsNewLayout: false };
397
+ }
398
+
399
+ currentLayoutName = layoutToUse;
400
+
401
+ const shouldCreateLayout = !!layoutToUse;
402
+ if (shouldCreateLayout) {
403
+ const timestamp = Date.now();
404
+ currentLayoutTimestamp = timestamp;
405
+
406
+ const layoutElement = document.createElement(layoutToUse);
407
+ (layoutElement as any)[ROUTER_CONTEXT] = context;
408
+ (layoutElement as any)[CREATED_AT] = timestamp;
409
+
410
+ return { element: layoutElement, needsNewLayout: true };
411
+ }
412
+
413
+ currentLayoutTimestamp = null;
414
+ return { element: null, needsNewLayout: true };
415
+ }
416
+
417
+ async function renderWithLayout(target: Element, pageElement: HTMLElement, transition: Transition | undefined, layoutElement: HTMLElement | null, needsNewLayout: boolean): Promise<void> {
418
+ const currentLayout = layoutElement || getCurrentLayoutElement(target);
419
+ if (!currentLayout) {
420
+ return;
421
+ }
422
+
423
+ const oldPageInLayout = currentLayout.querySelector('[slot="page"]') as HTMLElement | null;
424
+ const shouldTransition = !!(transition && oldPageInLayout);
425
+
426
+ if (shouldTransition) {
427
+ pageElement.setAttribute('slot', 'page');
428
+ await performTransition(currentLayout, oldPageInLayout!, pageElement, transition!);
429
+ if (needsNewLayout) {
430
+ target.innerHTML = '';
431
+ target.appendChild(currentLayout);
432
+ }
433
+ return;
434
+ }
435
+
436
+ const existingPages = currentLayout.querySelectorAll('[slot="page"]');
437
+ existingPages.forEach(page => page.remove());
438
+ pageElement.setAttribute('slot', 'page');
439
+ currentLayout.appendChild(pageElement);
440
+
441
+ if (needsNewLayout) {
442
+ target.innerHTML = '';
443
+ target.appendChild(currentLayout);
444
+ }
445
+ }
446
+
447
+ async function renderDirect(target: Element, pageElement: HTMLElement, transition: Transition | undefined): Promise<void> {
448
+ const currentElementInTarget = target.children[0] as HTMLElement | null;
449
+ const shouldTransition = !!(transition && currentElementInTarget);
450
+
451
+ if (shouldTransition) {
452
+ await performTransition(target, currentElementInTarget!, pageElement, transition!);
453
+ return;
454
+ }
455
+
456
+ target.innerHTML = '';
457
+ target.appendChild(pageElement);
458
+ }
459
+
236
460
  /**
237
461
  * Navigates to the specified path.
238
462
  * @param {string} path - The path to navigate to.
@@ -245,127 +469,68 @@ export function Router(options: RouterOptions): RouterInstance {
245
469
  throw new Error(`Target element not found: ${options.target}`);
246
470
  }
247
471
 
248
- let newPageElement: HTMLElement | null = null;
249
- let transition: Transition | undefined;
250
- let guards: Guard<any> | Guard<any>[] | undefined;
472
+ window.scrollTo(0, 0);
251
473
 
252
- // Home
253
- if ((path.trim() === '' || path === '/') && home) {
254
- // Find home route to get guards
474
+ const isHomePath = (path.trim() === '' || path === '/') && !!home;
475
+
476
+ if (isHomePath) {
255
477
  const homeRoute = routes.find(r => r.route.match('/'));
256
- guards = homeRoute?.guards;
257
-
258
- // Check guards before creating element
259
- if (guards) {
260
- const guardsArray = Array.isArray(guards) ? guards : [guards];
261
- for (const guard of guardsArray) {
262
- const allowed = await guard(context, {}); // No params for home route
263
- if (!allowed) {
264
- // Render 403 page
265
- if (_403) {
266
- newPageElement = document.createElement(_403);
267
- // Store context on 403 page too
268
- (newPageElement as any)[ROUTER_CONTEXT] = context;
269
- } else {
270
- const div = document.createElement('div');
271
- div.className = 'default-403';
272
- div.innerHTML = '<h1>403</h1><p>Unauthorized</p>';
273
- newPageElement = div;
274
- }
275
- // Don't perform transition for 403
276
- target.innerHTML = '';
277
- if (newPageElement) {
278
- target.appendChild(newPageElement);
279
- }
280
- currentPageElement = newPageElement;
281
- return;
282
- }
283
- }
478
+ const guardsAllowed = await checkGuards(homeRoute?.guards, {}, target);
479
+ if (!guardsAllowed) {
480
+ return;
284
481
  }
285
482
 
286
- newPageElement = document.createElement(home);
287
- // Store context on the page element
288
- (newPageElement as any)[ROUTER_CONTEXT] = context;
289
- const constructor = customElements.get(home);
290
- transition = (constructor as any)?.[PAGE_TRANSITION];
291
- } else {
292
-
293
- // Get the current route
294
- for (const route of routes) {
295
- const params = route.route.match(path);
296
- const is_match = params !== false;
297
-
298
- if (is_match) {
299
- // Check guards before creating element
300
- if (route.guards) {
301
- const guardsArray = Array.isArray(route.guards) ? route.guards : [route.guards];
302
- for (const guard of guardsArray) {
303
- const allowed = await guard(context, params as RouteParams);
304
- if (!allowed) {
305
- // Render 403 page
306
- if (_403) {
307
- newPageElement = document.createElement(_403);
308
- // Store context on 403 page too
309
- (newPageElement as any)[ROUTER_CONTEXT] = context;
310
- } else {
311
- const div = document.createElement('div');
312
- div.className = 'default-403';
313
- div.innerHTML = '<h1>403</h1><p>Unauthorized</p>';
314
- newPageElement = div;
315
- }
316
- // Don't perform transition for 403
317
- target.innerHTML = '';
318
- if (newPageElement) {
319
- target.appendChild(newPageElement);
320
- }
321
- currentPageElement = newPageElement;
322
- return;
323
- }
324
- }
325
- }
326
-
327
- newPageElement = document.createElement(route.tag);
328
- // Store context on the page element
329
- (newPageElement as any)[ROUTER_CONTEXT] = context;
330
- Object.keys(params).forEach(key => newPageElement!.setAttribute(key, params[key]));
331
- transition = route.transition;
332
- break;
333
- }
483
+ const { element, transition, layout } = createHomeElement();
484
+ const layoutToUse = determineLayout(layout);
485
+ const { element: layoutElement, needsNewLayout } = setupLayout(layoutToUse);
486
+ const finalTransition = transition || options.transition;
487
+
488
+ const hasLayout = layoutElement !== null || getCurrentLayoutElement(target) !== null;
489
+ if (hasLayout) {
490
+ await renderWithLayout(target, element, finalTransition, layoutElement, needsNewLayout);
491
+ return;
334
492
  }
493
+
494
+ await renderDirect(target, element, finalTransition);
495
+ return;
335
496
  }
336
-
337
- // 404
338
- if (!newPageElement) {
339
- if (_404) {
340
- newPageElement = document.createElement(_404);
341
- // Store context on 404 page too
342
- (newPageElement as any)[ROUTER_CONTEXT] = context;
343
- const constructor = customElements.get(_404);
344
- transition = (constructor as any)?.[PAGE_TRANSITION];
345
- } else {
346
- // Provide a default 404 page
347
- const div = document.createElement('div');
348
- div.className = 'default-404';
349
- div.innerHTML = '<h1>404</h1><p>Page not found</p>';
350
- newPageElement = div;
351
- }
497
+
498
+ const routeResult = await resolveRoute(path, target);
499
+
500
+ const isGuardsFailed = routeResult.result === RouteResult.GUARDS_FAILED;
501
+ if (isGuardsFailed) {
502
+ return;
352
503
  }
353
-
354
- // Use page-specific or global transition
355
- transition = transition || options.transition;
356
-
357
- // Perform transition
358
- if (transition && currentPageElement && currentPageElement.parentElement) {
359
- await performTransition(target, currentPageElement, newPageElement!, transition);
360
- } else {
361
- // No transition, just swap
362
- target.innerHTML = '';
363
- if (newPageElement) {
364
- target.appendChild(newPageElement);
504
+
505
+ const isSuccess = routeResult.result === RouteResult.SUCCESS;
506
+ if (isSuccess) {
507
+ const { element, transition, layout } = routeResult;
508
+ const layoutToUse = determineLayout(layout);
509
+ const { element: layoutElement, needsNewLayout } = setupLayout(layoutToUse);
510
+ const finalTransition = transition || options.transition;
511
+
512
+ const hasLayout = layoutElement !== null || getCurrentLayoutElement(target) !== null;
513
+ if (hasLayout) {
514
+ await renderWithLayout(target, element!, finalTransition, layoutElement, needsNewLayout);
515
+ return;
365
516
  }
517
+
518
+ await renderDirect(target, element!, finalTransition);
519
+ return;
366
520
  }
367
-
368
- currentPageElement = newPageElement;
521
+
522
+ const { element, transition, layout } = create404Element();
523
+ const layoutToUse = determineLayout(layout);
524
+ const { element: layoutElement, needsNewLayout } = setupLayout(layoutToUse);
525
+ const finalTransition = transition || options.transition;
526
+
527
+ const hasLayout = layoutElement !== null || getCurrentLayoutElement(target) !== null;
528
+ if (hasLayout) {
529
+ await renderWithLayout(target, element, finalTransition, layoutElement, needsNewLayout);
530
+ return;
531
+ }
532
+
533
+ await renderDirect(target, element, finalTransition);
369
534
  }
370
535
 
371
536
  async function performTransition(
package/src/symbols.ts CHANGED
@@ -35,8 +35,10 @@ export const EXPLICITLY_SET_PROPERTIES = getSymbol('explicitly-set-properties');
35
35
 
36
36
  // Router context symbol
37
37
  export const ROUTER_CONTEXT = getSymbol('router-context');
38
+ export const CURRENT_PAGE_MARKER = getSymbol('current-page-marker');
38
39
  export const CONTEXT_REQUEST_HANDLER = getSymbol('context-request-handler');
39
40
  export const PAGE_TRANSITION = getSymbol('page-transition');
41
+ export const CREATED_AT = getSymbol('created-at');
40
42
 
41
43
  // Lifecycle symbols
42
44
  export const READY_HANDLERS = getSymbol('ready-handlers');
@@ -71,22 +71,42 @@ export async function performTransition(
71
71
  const inEndStyles = transition.in ? parseStyles(transition.in) : { opacity: '1' };
72
72
 
73
73
  // Set container to relative positioning to allow absolute positioning
74
+ // Skip for layout elements to avoid jumpy transitions
74
75
  const containerStyle = (container as HTMLElement).style;
75
76
  const originalPosition = containerStyle.position;
76
- containerStyle.position = 'relative';
77
-
78
- // Style old element for transition
79
- oldElement.style.position = 'absolute';
80
- oldElement.style.top = '0';
81
- oldElement.style.left = '0';
82
- oldElement.style.width = '100%';
83
- oldElement.style.transition = `all ${outDuration}ms ease-in-out`;
84
-
85
- // Style new element with initial state
86
- newElement.style.position = 'absolute';
87
- newElement.style.top = '0';
88
- newElement.style.left = '0';
89
- newElement.style.width = '100%';
77
+ const isLayoutElement = container.tagName.includes('-') && container.shadowRoot;
78
+
79
+ if (!isLayoutElement) {
80
+ containerStyle.position = 'relative';
81
+ }
82
+
83
+ // Check if elements are slotted (inside a layout)
84
+ const isSlottedTransition = oldElement.hasAttribute('slot') || newElement.hasAttribute('slot');
85
+
86
+ if (isSlottedTransition) {
87
+ // For slotted elements, use absolute with width/height for crossfade
88
+ oldElement.style.position = 'absolute';
89
+ oldElement.style.width = '100%';
90
+ oldElement.style.height = '100%';
91
+ oldElement.style.transition = `opacity ${outDuration}ms ease-in-out`;
92
+
93
+ newElement.style.position = 'absolute';
94
+ newElement.style.width = '100%';
95
+ newElement.style.height = '100%';
96
+ newElement.style.transition = `opacity ${inDuration}ms ease-in-out`;
97
+ } else {
98
+ // Original absolute positioning for non-slotted elements
99
+ oldElement.style.position = 'absolute';
100
+ oldElement.style.top = '0';
101
+ oldElement.style.left = '0';
102
+ oldElement.style.width = '100%';
103
+ oldElement.style.transition = `all ${outDuration}ms ease-in-out`;
104
+
105
+ newElement.style.position = 'absolute';
106
+ newElement.style.top = '0';
107
+ newElement.style.left = '0';
108
+ newElement.style.width = '100%';
109
+ }
90
110
  Object.assign(newElement.style, inStartStyles);
91
111
  newElement.style.transition = `all ${inDuration}ms ease-in-out`;
92
112
 
@@ -114,16 +134,29 @@ export async function performTransition(
114
134
 
115
135
  // Cleanup
116
136
  oldElement.remove();
117
- newElement.style.position = '';
118
- newElement.style.top = '';
119
- newElement.style.left = '';
120
- newElement.style.width = '';
121
- newElement.style.transition = '';
137
+
138
+ if (isSlottedTransition) {
139
+ // Cleanup for slotted elements
140
+ newElement.style.position = '';
141
+ newElement.style.width = '';
142
+ newElement.style.height = '';
143
+ newElement.style.transition = '';
144
+ } else {
145
+ // Cleanup for non-slotted elements
146
+ newElement.style.position = '';
147
+ newElement.style.top = '';
148
+ newElement.style.left = '';
149
+ newElement.style.width = '';
150
+ newElement.style.transition = '';
151
+ }
152
+
122
153
  // Reset any transition styles
123
154
  Object.keys({...inStartStyles, ...inEndStyles}).forEach(prop => {
124
155
  newElement.style[prop as any] = '';
125
156
  });
126
- containerStyle.position = originalPosition;
157
+ if (!isLayoutElement) {
158
+ containerStyle.position = originalPosition;
159
+ }
127
160
  }
128
161
 
129
162
  /**
@@ -1,82 +0,0 @@
1
- export const containerStyles = `
2
- .container {
3
- max-width: 1200px;
4
- margin: 0 auto;
5
- padding: 2rem;
6
- }
7
-
8
- .btn {
9
- padding: 0.75rem 1.5rem;
10
- border-radius: 6px;
11
- text-decoration: none;
12
- font-weight: 600;
13
- transition: all 0.3s ease;
14
- cursor: pointer;
15
- border: none;
16
- display: inline-block;
17
- }
18
-
19
- .btn-primary {
20
- background: var(--primary-color);
21
- color: white;
22
- }
23
-
24
- .btn-primary:hover {
25
- background: var(--secondary-color);
26
- transform: translateY(-2px);
27
- }
28
-
29
- .btn-secondary {
30
- background: transparent;
31
- color: var(--primary-color);
32
- border: 2px solid var(--primary-color);
33
- }
34
-
35
- .btn-secondary:hover {
36
- background: var(--primary-color);
37
- color: white;
38
- }
39
- `;
40
-
41
- export const navbarStyles = `
42
- .navbar {
43
- background: var(--white);
44
- box-shadow: var(--shadow);
45
- position: sticky;
46
- top: 0;
47
- z-index: 100;
48
- }
49
-
50
- .nav-container {
51
- max-width: 1200px;
52
- margin: 0 auto;
53
- padding: 1rem 2rem;
54
- display: flex;
55
- justify-content: space-between;
56
- align-items: center;
57
- }
58
-
59
- .nav-brand {
60
- font-size: 1.5rem;
61
- font-weight: 700;
62
- color: var(--primary-color);
63
- }
64
-
65
- .nav-menu {
66
- display: flex;
67
- list-style: none;
68
- gap: 2rem;
69
- }
70
-
71
- .nav-link {
72
- color: var(--text-light);
73
- text-decoration: none;
74
- font-weight: 500;
75
- transition: color 0.3s;
76
- }
77
-
78
- .nav-link:hover,
79
- .nav-link.active {
80
- color: var(--primary-color);
81
- }
82
- `;