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 +148 -32
- package/package.json +36 -2
- package/src/element.ts +7 -0
- package/src/index.ts +1 -1
- package/src/router.ts +310 -145
- package/src/symbols.ts +2 -0
- package/src/transitions.ts +53 -20
- package/bin/templates/base/src/styles/shared.ts +0 -82
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
|
|
145
|
+
countElement!: HTMLSpanElement;
|
|
84
146
|
|
|
85
147
|
@query('.status')
|
|
86
|
-
statusElement
|
|
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
|
-
|
|
102
|
-
this.countElement.textContent = String(newCount);
|
|
103
|
-
}
|
|
163
|
+
this.countElement.textContent = String(newCount);
|
|
104
164
|
}
|
|
105
165
|
|
|
106
166
|
setStatus(status: string) {
|
|
107
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
605
|
+
element!: IUserElement;
|
|
549
606
|
|
|
550
|
-
async attach(element:
|
|
607
|
+
async attach(element: IUserElement) {
|
|
551
608
|
const response = await fetch('/api/users');
|
|
552
609
|
const users = await response.json();
|
|
553
|
-
|
|
610
|
+
element.setUsers(users);
|
|
554
611
|
}
|
|
555
612
|
|
|
556
|
-
async detach(element:
|
|
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
|
|
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
|
|
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.
|
|
3
|
+
"version": "1.11.0",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "
|
|
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
|
|
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
|
-
|
|
192
|
-
if (!
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
272
|
+
const path = getPath();
|
|
224
273
|
navigate(path);
|
|
225
274
|
}
|
|
226
275
|
|
|
227
|
-
function
|
|
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
|
-
|
|
249
|
-
let transition: Transition | undefined;
|
|
250
|
-
let guards: Guard<any> | Guard<any>[] | undefined;
|
|
472
|
+
window.scrollTo(0, 0);
|
|
251
473
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
474
|
+
const isHomePath = (path.trim() === '' || path === '/') && !!home;
|
|
475
|
+
|
|
476
|
+
if (isHomePath) {
|
|
255
477
|
const homeRoute = routes.find(r => r.route.match('/'));
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
const
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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
|
-
|
|
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');
|
package/src/transitions.ts
CHANGED
|
@@ -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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
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
|
-
`;
|