snice 1.2.0 → 1.4.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 +114 -4
- package/bin/snice.js +100 -0
- package/bin/templates/base/README.md +33 -0
- package/bin/templates/base/index.html +13 -0
- package/bin/templates/base/package.json +20 -0
- package/bin/templates/base/public/vite.svg +1 -0
- package/bin/templates/base/src/components/counter-button.ts +101 -0
- package/bin/templates/base/src/controllers/counter-controller.ts +24 -0
- package/bin/templates/base/src/main.ts +16 -0
- package/bin/templates/base/src/pages/about-page.ts +56 -0
- package/bin/templates/base/src/pages/home-page.ts +77 -0
- package/bin/templates/base/src/pages/not-found-page.ts +52 -0
- package/bin/templates/base/src/router.ts +15 -0
- package/bin/templates/base/src/styles/global.css +27 -0
- package/bin/templates/base/src/styles/shared.ts +82 -0
- package/bin/templates/base/src/types/counter-button.ts +7 -0
- package/bin/templates/base/tsconfig.json +21 -0
- package/bin/templates/base/vite.config.ts +21 -0
- package/package.json +7 -2
- package/src/element.ts +177 -5
- package/src/index.ts +1 -1
- package/src/symbols.ts +4 -1
package/README.md
CHANGED
|
@@ -2,6 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
A lightweight TypeScript framework for building web components with decorators and routing.
|
|
4
4
|
|
|
5
|
+
## Quick Start
|
|
6
|
+
|
|
7
|
+
Create a new Snice app with one command:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npx snice create-app my-app
|
|
11
|
+
cd my-app
|
|
12
|
+
npm run dev
|
|
13
|
+
```
|
|
14
|
+
|
|
5
15
|
## Core Philosophy: Imperative, Not Reactive
|
|
6
16
|
|
|
7
17
|
Snice takes an **imperative approach** to web components. Unlike reactive frameworks that automatically re-render when data changes, Snice components:
|
|
@@ -26,6 +36,7 @@ Snice provides a clear separation of concerns through decorators:
|
|
|
26
36
|
- **`@property`** - Declares properties that can reflect to attributes
|
|
27
37
|
- **`@query`** - Queries a single element from shadow DOM
|
|
28
38
|
- **`@queryAll`** - Queries multiple elements from shadow DOM
|
|
39
|
+
- **`@watch`** - Watches property changes and calls a method when they occur
|
|
29
40
|
|
|
30
41
|
### Event Decorators
|
|
31
42
|
- **`@on`** - Listens for events on elements
|
|
@@ -145,6 +156,101 @@ Use it with attributes:
|
|
|
145
156
|
```
|
|
146
157
|
|
|
147
158
|
|
|
159
|
+
## Watching Property Changes
|
|
160
|
+
|
|
161
|
+
Use `@watch` to imperatively update DOM when properties change:
|
|
162
|
+
|
|
163
|
+
```typescript
|
|
164
|
+
import { element, property, watch, query } from 'snice';
|
|
165
|
+
|
|
166
|
+
@element('theme-toggle')
|
|
167
|
+
class ThemeToggle extends HTMLElement {
|
|
168
|
+
@property({ reflect: true })
|
|
169
|
+
theme: 'light' | 'dark' = 'light';
|
|
170
|
+
|
|
171
|
+
@property({ type: Boolean })
|
|
172
|
+
animated = true;
|
|
173
|
+
|
|
174
|
+
@query('.toggle-button')
|
|
175
|
+
button?: HTMLElement;
|
|
176
|
+
|
|
177
|
+
@query('.theme-icon')
|
|
178
|
+
icon?: HTMLElement;
|
|
179
|
+
|
|
180
|
+
html() {
|
|
181
|
+
return `
|
|
182
|
+
<button class="toggle-button">
|
|
183
|
+
<span class="theme-icon">🌞</span>
|
|
184
|
+
</button>
|
|
185
|
+
`;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
@watch('theme')
|
|
189
|
+
onThemeChange(oldTheme: string, newTheme: string, propertyName: string) {
|
|
190
|
+
// propertyName will be 'theme'
|
|
191
|
+
// Update icon when theme changes
|
|
192
|
+
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);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
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() {
|
|
221
|
+
this.theme = this.theme === 'light' ? 'dark' : 'light';
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
**Key Points:**
|
|
227
|
+
- `@watch` methods are called when the property value changes
|
|
228
|
+
- Receives `oldValue`, `newValue`, and `propertyName` as parameters
|
|
229
|
+
- Perfect for imperatively updating DOM elements
|
|
230
|
+
- Can watch multiple properties with multiple decorators
|
|
231
|
+
- Works with both programmatic changes and attribute changes
|
|
232
|
+
|
|
233
|
+
You can watch multiple properties with a single decorator:
|
|
234
|
+
|
|
235
|
+
```typescript
|
|
236
|
+
@watch('width', 'height', 'scale')
|
|
237
|
+
updateDimensions(oldValue: number, newValue: number, propertyName: string) {
|
|
238
|
+
// Called when any of these properties change
|
|
239
|
+
console.log(`${propertyName} changed from ${oldValue} to ${newValue}`);
|
|
240
|
+
this.recalculateLayout();
|
|
241
|
+
}
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
Watch all property changes with the wildcard:
|
|
245
|
+
|
|
246
|
+
```typescript
|
|
247
|
+
@watch('*')
|
|
248
|
+
handleAnyPropertyChange(oldValue: any, newValue: any, propertyName: string) {
|
|
249
|
+
console.log(`Property ${propertyName} changed from ${oldValue} to ${newValue}`);
|
|
250
|
+
// Useful for debugging or when all properties affect the same output
|
|
251
|
+
}
|
|
252
|
+
```
|
|
253
|
+
|
|
148
254
|
## Queries
|
|
149
255
|
|
|
150
256
|
Query single elements with `@query`:
|
|
@@ -315,13 +421,16 @@ class AboutPage extends HTMLElement {
|
|
|
315
421
|
}
|
|
316
422
|
}
|
|
317
423
|
|
|
318
|
-
// Page with URL parameter
|
|
319
|
-
|
|
424
|
+
// Page with URL parameter
|
|
425
|
+
import { property } from 'snice';
|
|
426
|
+
|
|
427
|
+
@page({ tag: 'user-page', routes: ['/users/:userId'] })
|
|
320
428
|
class UserPage extends HTMLElement {
|
|
321
|
-
|
|
429
|
+
@property()
|
|
430
|
+
userId = '';
|
|
322
431
|
|
|
323
432
|
html() {
|
|
324
|
-
return `<h1>User ${this.
|
|
433
|
+
return `<h1>User ${this.userId}</h1>`;
|
|
325
434
|
}
|
|
326
435
|
}
|
|
327
436
|
|
|
@@ -565,6 +674,7 @@ Use the same card with different controllers:
|
|
|
565
674
|
| `@property(options)` | Declares a property that can reflect to attributes | `@property({ type: Boolean, reflect: true })` |
|
|
566
675
|
| `@query(selector)` | Queries a single element from shadow DOM | `@query('.button')` |
|
|
567
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('*')` |
|
|
568
678
|
|
|
569
679
|
### Event Decorators
|
|
570
680
|
|
package/bin/snice.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { fileURLToPath } from 'url';
|
|
4
|
+
import { dirname, join, resolve, basename } from 'path';
|
|
5
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, cpSync } from 'fs';
|
|
6
|
+
|
|
7
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
8
|
+
const __dirname = dirname(__filename);
|
|
9
|
+
|
|
10
|
+
const args = process.argv.slice(2);
|
|
11
|
+
const command = args[0];
|
|
12
|
+
|
|
13
|
+
if (command === 'create-app') {
|
|
14
|
+
const projectPath = args[1] || '.';
|
|
15
|
+
createApp(projectPath);
|
|
16
|
+
} else {
|
|
17
|
+
console.log(`
|
|
18
|
+
Snice CLI
|
|
19
|
+
|
|
20
|
+
Usage:
|
|
21
|
+
snice create-app <project-name> Create a new Snice application
|
|
22
|
+
snice create-app . Initialize in current directory
|
|
23
|
+
|
|
24
|
+
Examples:
|
|
25
|
+
snice create-app my-app
|
|
26
|
+
npx snice create-app my-app
|
|
27
|
+
`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function createApp(projectPath) {
|
|
31
|
+
const targetDir = resolve(process.cwd(), projectPath);
|
|
32
|
+
const projectName = projectPath === '.' ? basename(process.cwd()) : basename(targetDir);
|
|
33
|
+
|
|
34
|
+
console.log(`\n🚀 Creating Snice app in ${targetDir}...\n`);
|
|
35
|
+
|
|
36
|
+
// Check if directory exists and is empty
|
|
37
|
+
if (projectPath !== '.') {
|
|
38
|
+
if (existsSync(targetDir)) {
|
|
39
|
+
const files = readdirSync(targetDir);
|
|
40
|
+
if (files.length > 0 && !files.every(f => f.startsWith('.'))) {
|
|
41
|
+
console.error(`❌ Directory ${targetDir} is not empty!`);
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|
|
44
|
+
} else {
|
|
45
|
+
mkdirSync(targetDir, { recursive: true });
|
|
46
|
+
}
|
|
47
|
+
} else {
|
|
48
|
+
// Check current directory
|
|
49
|
+
const files = readdirSync(targetDir);
|
|
50
|
+
const hasNonDotFiles = files.some(f => !f.startsWith('.') && f !== 'node_modules');
|
|
51
|
+
if (hasNonDotFiles) {
|
|
52
|
+
console.error(`❌ Current directory is not empty!`);
|
|
53
|
+
process.exit(1);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Path to templates
|
|
58
|
+
const templateDir = join(__dirname, 'templates', 'base');
|
|
59
|
+
|
|
60
|
+
// Copy template files
|
|
61
|
+
copyTemplateFiles(templateDir, targetDir, projectName);
|
|
62
|
+
|
|
63
|
+
console.log(`\n✨ Project created successfully!\n`);
|
|
64
|
+
console.log('Next steps:');
|
|
65
|
+
|
|
66
|
+
if (projectPath !== '.') {
|
|
67
|
+
console.log(` cd ${projectPath}`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
console.log(' npm install');
|
|
71
|
+
console.log(' npm run dev\n');
|
|
72
|
+
console.log('Happy coding! 🎉\n');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function copyTemplateFiles(sourceDir, targetDir, projectName) {
|
|
76
|
+
const files = readdirSync(sourceDir, { withFileTypes: true });
|
|
77
|
+
|
|
78
|
+
for (const file of files) {
|
|
79
|
+
const sourcePath = join(sourceDir, file.name);
|
|
80
|
+
const targetPath = join(targetDir, file.name);
|
|
81
|
+
|
|
82
|
+
if (file.isDirectory()) {
|
|
83
|
+
// Create directory and recursively copy contents
|
|
84
|
+
if (!existsSync(targetPath)) {
|
|
85
|
+
mkdirSync(targetPath, { recursive: true });
|
|
86
|
+
}
|
|
87
|
+
copyTemplateFiles(sourcePath, targetPath, projectName);
|
|
88
|
+
} else {
|
|
89
|
+
// Read file, replace placeholders, and write to target
|
|
90
|
+
console.log(` Creating ${file.name}...`);
|
|
91
|
+
|
|
92
|
+
let content = readFileSync(sourcePath, 'utf8');
|
|
93
|
+
|
|
94
|
+
// Replace {{projectName}} placeholders
|
|
95
|
+
content = content.replace(/\{\{projectName\}\}/g, projectName);
|
|
96
|
+
|
|
97
|
+
writeFileSync(targetPath, content);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# {{projectName}}
|
|
2
|
+
|
|
3
|
+
A Snice application
|
|
4
|
+
|
|
5
|
+
## Development
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm run dev
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Build
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npm run build
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Preview Production Build
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
npm run preview
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Project Structure
|
|
24
|
+
|
|
25
|
+
- `src/pages/` - Application pages/routes
|
|
26
|
+
- `src/components/` - Reusable components
|
|
27
|
+
- `src/controllers/` - Business logic controllers
|
|
28
|
+
- `src/styles/` - Global styles and shared styles
|
|
29
|
+
- `src/types/` - TypeScript interfaces and types
|
|
30
|
+
- `src/router.ts` - Router configuration
|
|
31
|
+
- `src/main.ts` - Application entry point
|
|
32
|
+
|
|
33
|
+
Built with [Snice](https://github.com/yourusername/snice)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>{{projectName}}</title>
|
|
7
|
+
<link rel="icon" type="image/svg+xml" href="/vite.svg">
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<div id="app"></div>
|
|
11
|
+
<script type="module" src="/src/main.ts"></script>
|
|
12
|
+
</body>
|
|
13
|
+
</html>
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "{{projectName}}",
|
|
3
|
+
"private": true,
|
|
4
|
+
"version": "0.0.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "vite",
|
|
8
|
+
"build": "tsc && vite build",
|
|
9
|
+
"type-check": "tsc --noEmit"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"snice": "^1.2.0"
|
|
13
|
+
},
|
|
14
|
+
"devDependencies": {
|
|
15
|
+
"@types/node": "^20.0.0",
|
|
16
|
+
"terser": "^5.24.0",
|
|
17
|
+
"typescript": "^5.3.3",
|
|
18
|
+
"vite": "^5.0.10"
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { element, property, query, on, dispatch } from 'snice';
|
|
2
|
+
import type { ICounterButton } from '../types/counter-button';
|
|
3
|
+
|
|
4
|
+
@element('counter-button')
|
|
5
|
+
export class CounterButton extends HTMLElement implements ICounterButton {
|
|
6
|
+
@property({ type: Number })
|
|
7
|
+
count = 0;
|
|
8
|
+
|
|
9
|
+
@query('.count')
|
|
10
|
+
countDisplay?: HTMLElement;
|
|
11
|
+
|
|
12
|
+
html() {
|
|
13
|
+
return /*html*/`
|
|
14
|
+
<div class="counter">
|
|
15
|
+
<button class="btn minus">-</button>
|
|
16
|
+
<span class="count">${this.count}</span>
|
|
17
|
+
<button class="btn plus">+</button>
|
|
18
|
+
</div>
|
|
19
|
+
`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
css() {
|
|
23
|
+
return /*css*/`
|
|
24
|
+
.counter {
|
|
25
|
+
display: inline-flex;
|
|
26
|
+
align-items: center;
|
|
27
|
+
gap: 1rem;
|
|
28
|
+
padding: 1rem;
|
|
29
|
+
background: white;
|
|
30
|
+
border-radius: 8px;
|
|
31
|
+
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
.count {
|
|
35
|
+
font-size: 1.5rem;
|
|
36
|
+
font-weight: bold;
|
|
37
|
+
min-width: 3rem;
|
|
38
|
+
text-align: center;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
.btn {
|
|
42
|
+
width: 2rem;
|
|
43
|
+
height: 2rem;
|
|
44
|
+
border: 2px solid var(--primary-color);
|
|
45
|
+
background: white;
|
|
46
|
+
color: var(--primary-color);
|
|
47
|
+
border-radius: 4px;
|
|
48
|
+
cursor: pointer;
|
|
49
|
+
font-size: 1.2rem;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
.btn:hover {
|
|
53
|
+
background: var(--primary-color);
|
|
54
|
+
color: white;
|
|
55
|
+
}
|
|
56
|
+
`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Imperative methods that controller can call
|
|
60
|
+
setCount(value: number) {
|
|
61
|
+
this.count = value;
|
|
62
|
+
this.updateDisplay();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
@dispatch('count-changed')
|
|
66
|
+
increment() {
|
|
67
|
+
this.count++;
|
|
68
|
+
this.updateDisplay();
|
|
69
|
+
return { count: this.count };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
@dispatch('count-changed')
|
|
73
|
+
decrement() {
|
|
74
|
+
this.count--;
|
|
75
|
+
this.updateDisplay();
|
|
76
|
+
return { count: this.count };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
@dispatch('count-changed')
|
|
80
|
+
reset() {
|
|
81
|
+
this.count = 0;
|
|
82
|
+
this.updateDisplay();
|
|
83
|
+
return { count: this.count };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private updateDisplay() {
|
|
87
|
+
if (this.countDisplay) {
|
|
88
|
+
this.countDisplay.textContent = String(this.count);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
@on('click', '.plus')
|
|
93
|
+
handlePlus() {
|
|
94
|
+
this.increment();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
@on('click', '.minus')
|
|
98
|
+
handleMinus() {
|
|
99
|
+
this.decrement();
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { controller, on } from 'snice';
|
|
2
|
+
import type { ICounterButton } from '../types/counter-button';
|
|
3
|
+
|
|
4
|
+
@controller('counter')
|
|
5
|
+
export class CounterController {
|
|
6
|
+
element!: ICounterButton;
|
|
7
|
+
|
|
8
|
+
async attach() {
|
|
9
|
+
// Load saved count from localStorage
|
|
10
|
+
const saved = localStorage.getItem('counter-value');
|
|
11
|
+
if (saved) {
|
|
12
|
+
this.element.setCount(parseInt(saved));
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async detach() {
|
|
17
|
+
// any clean up you need
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
@on('count-changed')
|
|
21
|
+
handleCountChanged(e: CustomEvent) {
|
|
22
|
+
localStorage.setItem('counter-value', String(e.detail.count));
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { initialize } from './router';
|
|
2
|
+
import './styles/global.css';
|
|
3
|
+
|
|
4
|
+
// Import components
|
|
5
|
+
import './components/counter-button';
|
|
6
|
+
|
|
7
|
+
// Import controllers
|
|
8
|
+
import './controllers/counter-controller';
|
|
9
|
+
|
|
10
|
+
// Import pages
|
|
11
|
+
import './pages/home-page';
|
|
12
|
+
import './pages/about-page';
|
|
13
|
+
import './pages/not-found-page';
|
|
14
|
+
|
|
15
|
+
// Initialize router
|
|
16
|
+
initialize();
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { page } from '../router';
|
|
2
|
+
|
|
3
|
+
@page({ tag: 'about-page', routes: ['/about'] })
|
|
4
|
+
export class AboutPage extends HTMLElement {
|
|
5
|
+
html() {
|
|
6
|
+
return /*html*/`
|
|
7
|
+
<div class="container">
|
|
8
|
+
<h1>About</h1>
|
|
9
|
+
<p>This app was built with Snice, a modern web components framework.</p>
|
|
10
|
+
<p>Version 1.0.0</p>
|
|
11
|
+
|
|
12
|
+
<div class="nav">
|
|
13
|
+
<a href="#/" class="btn">Back to Home</a>
|
|
14
|
+
</div>
|
|
15
|
+
</div>
|
|
16
|
+
`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
css() {
|
|
20
|
+
return /*css*/`
|
|
21
|
+
.container {
|
|
22
|
+
padding: 3rem;
|
|
23
|
+
max-width: 800px;
|
|
24
|
+
margin: 0 auto;
|
|
25
|
+
text-align: center;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
h1 {
|
|
29
|
+
color: var(--primary-color);
|
|
30
|
+
margin-bottom: 1rem;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
p {
|
|
34
|
+
color: var(--text-light);
|
|
35
|
+
margin-bottom: 1rem;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.nav {
|
|
39
|
+
margin-top: 3rem;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.btn {
|
|
43
|
+
display: inline-block;
|
|
44
|
+
padding: 0.75rem 1.5rem;
|
|
45
|
+
background: var(--primary-color);
|
|
46
|
+
color: white;
|
|
47
|
+
text-decoration: none;
|
|
48
|
+
border-radius: 6px;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
.btn:hover {
|
|
52
|
+
background: var(--secondary-color);
|
|
53
|
+
}
|
|
54
|
+
`;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { page } from '../router';
|
|
2
|
+
|
|
3
|
+
@page({ tag: 'home-page', routes: ['/'] })
|
|
4
|
+
export class HomePage extends HTMLElement {
|
|
5
|
+
html() {
|
|
6
|
+
return /*html*/`
|
|
7
|
+
<div class="container">
|
|
8
|
+
<h1>Welcome to {{projectName}}</h1>
|
|
9
|
+
<p>Built with Snice</p>
|
|
10
|
+
|
|
11
|
+
<div class="demo-section">
|
|
12
|
+
<h2>Interactive Counter Demo</h2>
|
|
13
|
+
<p>This counter persists its state using a controller:</p>
|
|
14
|
+
<counter-button controller="counter"></counter-button>
|
|
15
|
+
</div>
|
|
16
|
+
|
|
17
|
+
<div class="nav">
|
|
18
|
+
<a href="#/about" class="btn">About</a>
|
|
19
|
+
</div>
|
|
20
|
+
</div>
|
|
21
|
+
`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
css() {
|
|
25
|
+
return /*css*/`
|
|
26
|
+
.container {
|
|
27
|
+
padding: 3rem;
|
|
28
|
+
text-align: center;
|
|
29
|
+
max-width: 800px;
|
|
30
|
+
margin: 0 auto;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
h1 {
|
|
34
|
+
color: var(--primary-color);
|
|
35
|
+
margin-bottom: 1rem;
|
|
36
|
+
font-size: 2.5rem;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
h2 {
|
|
40
|
+
color: var(--primary-color);
|
|
41
|
+
margin-bottom: 1rem;
|
|
42
|
+
font-size: 1.5rem;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
p {
|
|
46
|
+
color: var(--text-light);
|
|
47
|
+
margin-bottom: 2rem;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
.demo-section {
|
|
51
|
+
margin: 3rem 0;
|
|
52
|
+
padding: 2rem;
|
|
53
|
+
background: rgba(255, 255, 255, 0.5);
|
|
54
|
+
border-radius: 12px;
|
|
55
|
+
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
.nav {
|
|
59
|
+
margin-top: 3rem;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
.btn {
|
|
63
|
+
display: inline-block;
|
|
64
|
+
padding: 0.75rem 1.5rem;
|
|
65
|
+
background: var(--primary-color);
|
|
66
|
+
color: white;
|
|
67
|
+
text-decoration: none;
|
|
68
|
+
border-radius: 6px;
|
|
69
|
+
transition: background 0.3s ease;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.btn:hover {
|
|
73
|
+
background: var(--secondary-color);
|
|
74
|
+
}
|
|
75
|
+
`;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { page } from '../router';
|
|
2
|
+
|
|
3
|
+
@page({ tag: 'not-found-page', routes: ['/404'] })
|
|
4
|
+
export class NotFoundPage extends HTMLElement {
|
|
5
|
+
html() {
|
|
6
|
+
return /*html*/`
|
|
7
|
+
<div class="container">
|
|
8
|
+
<h1>404</h1>
|
|
9
|
+
<p>Page not found</p>
|
|
10
|
+
<a href="#/" class="btn">Go Home</a>
|
|
11
|
+
</div>
|
|
12
|
+
`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
css() {
|
|
16
|
+
return /*css*/`
|
|
17
|
+
.container {
|
|
18
|
+
padding: 3rem;
|
|
19
|
+
text-align: center;
|
|
20
|
+
min-height: 100vh;
|
|
21
|
+
display: flex;
|
|
22
|
+
flex-direction: column;
|
|
23
|
+
justify-content: center;
|
|
24
|
+
align-items: center;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
h1 {
|
|
28
|
+
font-size: 4rem;
|
|
29
|
+
color: var(--primary-color);
|
|
30
|
+
margin-bottom: 1rem;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
p {
|
|
34
|
+
color: var(--text-light);
|
|
35
|
+
margin-bottom: 2rem;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.btn {
|
|
39
|
+
display: inline-block;
|
|
40
|
+
padding: 0.75rem 1.5rem;
|
|
41
|
+
background: var(--primary-color);
|
|
42
|
+
color: white;
|
|
43
|
+
text-decoration: none;
|
|
44
|
+
border-radius: 6px;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
.btn:hover {
|
|
48
|
+
background: var(--secondary-color);
|
|
49
|
+
}
|
|
50
|
+
`;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { Router } from 'snice';
|
|
2
|
+
|
|
3
|
+
const { page, initialize, navigate } = Router({
|
|
4
|
+
target: '#app',
|
|
5
|
+
routing_type: 'hash',
|
|
6
|
+
transition: {
|
|
7
|
+
mode: 'simultaneous',
|
|
8
|
+
outDuration: 200,
|
|
9
|
+
inDuration: 200,
|
|
10
|
+
out: 'opacity: 0; transform: translateX(-10px);',
|
|
11
|
+
in: 'opacity: 1; transform: translateX(0);'
|
|
12
|
+
}
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
export { page, initialize, navigate };
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
--primary-color: #667eea;
|
|
3
|
+
--secondary-color: #764ba2;
|
|
4
|
+
--text-color: #333;
|
|
5
|
+
--text-light: #666;
|
|
6
|
+
--bg-color: #f7f7f7;
|
|
7
|
+
--white: #ffffff;
|
|
8
|
+
--shadow: 0 2px 4px rgba(0,0,0,0.1);
|
|
9
|
+
--shadow-lg: 0 10px 30px rgba(0,0,0,0.15);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
* {
|
|
13
|
+
margin: 0;
|
|
14
|
+
padding: 0;
|
|
15
|
+
box-sizing: border-box;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
body {
|
|
19
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
|
20
|
+
color: var(--text-color);
|
|
21
|
+
background: var(--bg-color);
|
|
22
|
+
line-height: 1.6;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
#app {
|
|
26
|
+
min-height: 100vh;
|
|
27
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
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
|
+
`;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2020",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
|
6
|
+
"skipLibCheck": true,
|
|
7
|
+
"moduleResolution": "bundler",
|
|
8
|
+
"allowImportingTsExtensions": true,
|
|
9
|
+
"resolveJsonModule": true,
|
|
10
|
+
"isolatedModules": true,
|
|
11
|
+
"noEmit": true,
|
|
12
|
+
"strict": true,
|
|
13
|
+
"noUnusedLocals": true,
|
|
14
|
+
"noUnusedParameters": true,
|
|
15
|
+
"noFallthroughCasesInSwitch": true,
|
|
16
|
+
"experimentalDecorators": true,
|
|
17
|
+
"emitDecoratorMetadata": true,
|
|
18
|
+
"types": ["vite/client", "node"]
|
|
19
|
+
},
|
|
20
|
+
"include": ["src"]
|
|
21
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { defineConfig } from 'vite';
|
|
2
|
+
|
|
3
|
+
export default defineConfig({
|
|
4
|
+
build: {
|
|
5
|
+
target: 'es2015',
|
|
6
|
+
minify: 'terser',
|
|
7
|
+
cssMinify: true,
|
|
8
|
+
rollupOptions: {
|
|
9
|
+
output: {
|
|
10
|
+
manualChunks: {
|
|
11
|
+
vendor: ['snice']
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
sourcemap: true,
|
|
16
|
+
chunkSizeWarningLimit: 500
|
|
17
|
+
},
|
|
18
|
+
esbuild: {
|
|
19
|
+
drop: process.env.NODE_ENV === 'production' ? ['debugger'] : []
|
|
20
|
+
}
|
|
21
|
+
});
|
package/package.json
CHANGED
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "snice",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "A TypeScript library",
|
|
6
6
|
"main": "src/index.ts",
|
|
7
7
|
"module": "src/index.ts",
|
|
8
8
|
"types": "src/index.ts",
|
|
9
|
+
"bin": {
|
|
10
|
+
"snice": "./bin/snice.js"
|
|
11
|
+
},
|
|
9
12
|
"files": [
|
|
10
13
|
"src",
|
|
14
|
+
"bin",
|
|
15
|
+
"templates",
|
|
11
16
|
"!src/**/*.test.ts",
|
|
12
17
|
"!src/**/*.spec.ts"
|
|
13
18
|
],
|
|
@@ -40,7 +45,7 @@
|
|
|
40
45
|
"@vitest/ui": "^1.0.0",
|
|
41
46
|
"happy-dom": "^12.0.0",
|
|
42
47
|
"semantic-release": "^24.2.7",
|
|
43
|
-
"typescript": "^5.
|
|
48
|
+
"typescript": "^5.9.2",
|
|
44
49
|
"vite": "^5.0.10",
|
|
45
50
|
"vitest": "^1.0.0"
|
|
46
51
|
}
|
package/src/element.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
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 } from './symbols';
|
|
3
|
+
import { IS_ELEMENT_CLASS, READY_PROMISE, READY_RESOLVE, CONTROLLER, PROPERTIES, PROPERTY_VALUES, PROPERTIES_INITIALIZED, PROPERTY_WATCHERS, EXPLICITLY_SET_PROPERTIES } from './symbols';
|
|
4
4
|
|
|
5
5
|
export function element(tagName: string) {
|
|
6
6
|
return function (constructor: any) {
|
|
@@ -12,11 +12,25 @@ export function element(tagName: string) {
|
|
|
12
12
|
const originalDisconnectedCallback = constructor.prototype.disconnectedCallback;
|
|
13
13
|
const originalAttributeChangedCallback = constructor.prototype.attributeChangedCallback;
|
|
14
14
|
|
|
15
|
-
// Add 'controller' to observed attributes
|
|
15
|
+
// Add 'controller' and all reflected properties to observed attributes
|
|
16
16
|
const observedAttributes = constructor.observedAttributes || [];
|
|
17
17
|
if (!observedAttributes.includes('controller')) {
|
|
18
18
|
observedAttributes.push('controller');
|
|
19
19
|
}
|
|
20
|
+
|
|
21
|
+
// Add all reflected properties to observed attributes
|
|
22
|
+
const properties = constructor[PROPERTIES];
|
|
23
|
+
if (properties) {
|
|
24
|
+
for (const [propName, propOptions] of properties) {
|
|
25
|
+
if (propOptions.reflect) {
|
|
26
|
+
const attributeName = typeof propOptions.attribute === 'string' ? propOptions.attribute : propName;
|
|
27
|
+
if (!observedAttributes.includes(attributeName)) {
|
|
28
|
+
observedAttributes.push(attributeName);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
20
34
|
Object.defineProperty(constructor, 'observedAttributes', {
|
|
21
35
|
get() { return observedAttributes; },
|
|
22
36
|
configurable: true
|
|
@@ -71,6 +85,53 @@ export function element(tagName: string) {
|
|
|
71
85
|
}
|
|
72
86
|
|
|
73
87
|
try {
|
|
88
|
+
// Initialize properties from attributes before rendering
|
|
89
|
+
const properties = constructor[PROPERTIES];
|
|
90
|
+
if (properties) {
|
|
91
|
+
for (const [propName, propOptions] of properties) {
|
|
92
|
+
if (propOptions.reflect) {
|
|
93
|
+
const attributeName = typeof propOptions.attribute === 'string' ? propOptions.attribute : propName;
|
|
94
|
+
// Only read from attribute if property hasn't been set yet
|
|
95
|
+
if (this.hasAttribute(attributeName) && !(propName in (this[PROPERTY_VALUES] || {}))) {
|
|
96
|
+
// Attribute exists, parse and set the property value
|
|
97
|
+
const attrValue = this.getAttribute(attributeName);
|
|
98
|
+
|
|
99
|
+
// Mark as explicitly set since it came from an attribute
|
|
100
|
+
if (!this[EXPLICITLY_SET_PROPERTIES]) {
|
|
101
|
+
this[EXPLICITLY_SET_PROPERTIES] = new Set();
|
|
102
|
+
}
|
|
103
|
+
this[EXPLICITLY_SET_PROPERTIES].add(propName);
|
|
104
|
+
|
|
105
|
+
if (propOptions.type === Boolean) {
|
|
106
|
+
this[propName] = attrValue !== null;
|
|
107
|
+
} else if (propOptions.type === Number) {
|
|
108
|
+
this[propName] = Number(attrValue);
|
|
109
|
+
} else {
|
|
110
|
+
this[propName] = attrValue;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Mark that properties have been initialized
|
|
118
|
+
this[PROPERTIES_INITIALIZED] = true;
|
|
119
|
+
|
|
120
|
+
// Reflect properties that were explicitly set before connection
|
|
121
|
+
// but skip default values that were never explicitly set
|
|
122
|
+
if (properties && this[EXPLICITLY_SET_PROPERTIES]) {
|
|
123
|
+
for (const [propName, propOptions] of properties) {
|
|
124
|
+
if (propOptions.reflect && this[EXPLICITLY_SET_PROPERTIES].has(propName) && propName in this[PROPERTY_VALUES]) {
|
|
125
|
+
const value = this[PROPERTY_VALUES][propName];
|
|
126
|
+
const attributeName = typeof propOptions.attribute === 'string' ? propOptions.attribute : propName;
|
|
127
|
+
|
|
128
|
+
if (value !== null && value !== undefined && value !== false) {
|
|
129
|
+
this.setAttribute(attributeName, String(value));
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
74
135
|
// Clean up any existing event handlers first (for reconnection)
|
|
75
136
|
cleanupEventHandlers(this);
|
|
76
137
|
|
|
@@ -150,6 +211,47 @@ export function element(tagName: string) {
|
|
|
150
211
|
originalAttributeChangedCallback?.call(this, name, oldValue, newValue);
|
|
151
212
|
if (name === 'controller') {
|
|
152
213
|
this.controller = newValue;
|
|
214
|
+
} else {
|
|
215
|
+
// Handle reflected properties
|
|
216
|
+
const properties = constructor[PROPERTIES];
|
|
217
|
+
if (properties) {
|
|
218
|
+
for (const [propName, propOptions] of properties) {
|
|
219
|
+
if (propOptions.reflect) {
|
|
220
|
+
const attributeName = typeof propOptions.attribute === 'string' ? propOptions.attribute : propName;
|
|
221
|
+
if (attributeName === name) {
|
|
222
|
+
// Check if the current property value already matches to avoid feedback loops
|
|
223
|
+
const currentValue = this[PROPERTY_VALUES]?.[propName];
|
|
224
|
+
|
|
225
|
+
// Parse the new value based on type
|
|
226
|
+
let parsedValue: any;
|
|
227
|
+
if (propOptions.type === Boolean) {
|
|
228
|
+
parsedValue = newValue !== null;
|
|
229
|
+
} else if (propOptions.type === Number) {
|
|
230
|
+
parsedValue = Number(newValue);
|
|
231
|
+
} else {
|
|
232
|
+
// If no type specified, try to infer from current value type
|
|
233
|
+
if (typeof currentValue === 'number' && newValue !== null) {
|
|
234
|
+
parsedValue = Number(newValue);
|
|
235
|
+
} else {
|
|
236
|
+
parsedValue = newValue;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Only update if the value actually changed
|
|
241
|
+
if (currentValue !== parsedValue) {
|
|
242
|
+
// Mark as explicitly set since it came from an attribute change
|
|
243
|
+
if (!this[EXPLICITLY_SET_PROPERTIES]) {
|
|
244
|
+
this[EXPLICITLY_SET_PROPERTIES] = new Set();
|
|
245
|
+
}
|
|
246
|
+
this[EXPLICITLY_SET_PROPERTIES].add(propName);
|
|
247
|
+
|
|
248
|
+
this[propName] = parsedValue;
|
|
249
|
+
}
|
|
250
|
+
break;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
153
255
|
}
|
|
154
256
|
};
|
|
155
257
|
|
|
@@ -181,18 +283,64 @@ export function property(options?: PropertyOptions) {
|
|
|
181
283
|
if (!this[PROPERTY_VALUES]) {
|
|
182
284
|
this[PROPERTY_VALUES] = {};
|
|
183
285
|
}
|
|
286
|
+
if (!this[EXPLICITLY_SET_PROPERTIES]) {
|
|
287
|
+
this[EXPLICITLY_SET_PROPERTIES] = new Set();
|
|
288
|
+
}
|
|
289
|
+
|
|
184
290
|
const oldValue = this[PROPERTY_VALUES][propertyKey];
|
|
185
291
|
|
|
186
292
|
// Don't update if value hasn't changed
|
|
187
293
|
if (oldValue === value) return;
|
|
188
294
|
|
|
295
|
+
// Only mark as explicitly set if there was a previous value
|
|
296
|
+
// (i.e., this is not the initial default value being set during class initialization)
|
|
297
|
+
if (oldValue !== undefined) {
|
|
298
|
+
this[EXPLICITLY_SET_PROPERTIES].add(propertyKey);
|
|
299
|
+
}
|
|
300
|
+
|
|
189
301
|
this[PROPERTY_VALUES][propertyKey] = value;
|
|
190
302
|
|
|
191
|
-
|
|
303
|
+
// Only reflect to attributes if:
|
|
304
|
+
// 1. Properties have been initialized from attributes
|
|
305
|
+
// 2. The property was explicitly set (not just default value)
|
|
306
|
+
// This prevents default values from creating attributes
|
|
307
|
+
if (options?.reflect && this.setAttribute && this[PROPERTIES_INITIALIZED] && this[EXPLICITLY_SET_PROPERTIES].has(propertyKey)) {
|
|
308
|
+
const attributeName = typeof options.attribute === 'string' ? options.attribute : propertyKey;
|
|
309
|
+
|
|
192
310
|
if (value === null || value === undefined || value === false) {
|
|
193
|
-
this.removeAttribute(
|
|
311
|
+
this.removeAttribute(attributeName);
|
|
194
312
|
} else {
|
|
195
|
-
this.setAttribute(
|
|
313
|
+
this.setAttribute(attributeName, String(value));
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Call watchers for this property
|
|
318
|
+
const watchers = constructor[PROPERTY_WATCHERS];
|
|
319
|
+
if (watchers) {
|
|
320
|
+
// Call specific property watchers
|
|
321
|
+
if (watchers.has(propertyKey)) {
|
|
322
|
+
const propertyWatchers = watchers.get(propertyKey);
|
|
323
|
+
for (const watcher of propertyWatchers) {
|
|
324
|
+
try {
|
|
325
|
+
// Always pass oldValue, newValue, and propertyName
|
|
326
|
+
watcher.method.call(this, oldValue, value, propertyKey);
|
|
327
|
+
} catch (error) {
|
|
328
|
+
console.error(`Error in @watch('${propertyKey}') method ${watcher.methodName}:`, error);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Call wildcard watchers (watching "*")
|
|
334
|
+
if (watchers.has('*')) {
|
|
335
|
+
const wildcardWatchers = watchers.get('*');
|
|
336
|
+
for (const watcher of wildcardWatchers) {
|
|
337
|
+
try {
|
|
338
|
+
// Same signature for consistency
|
|
339
|
+
watcher.method.call(this, oldValue, value, propertyKey);
|
|
340
|
+
} catch (error) {
|
|
341
|
+
console.error(`Error in @watch('*') method ${watcher.methodName}:`, error);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
196
344
|
}
|
|
197
345
|
}
|
|
198
346
|
|
|
@@ -256,4 +404,28 @@ export interface PropertyOptions {
|
|
|
256
404
|
export interface PropertyConverter {
|
|
257
405
|
fromAttribute?(value: string | null, type?: any): any;
|
|
258
406
|
toAttribute?(value: any, type?: any): string | null;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
export function watch(...propertyNames: string[]) {
|
|
410
|
+
return function (target: any, methodName: string, descriptor: PropertyDescriptor) {
|
|
411
|
+
const constructor = target.constructor;
|
|
412
|
+
|
|
413
|
+
if (!constructor[PROPERTY_WATCHERS]) {
|
|
414
|
+
constructor[PROPERTY_WATCHERS] = new Map();
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// Store the watcher method for each property
|
|
418
|
+
for (const propertyName of propertyNames) {
|
|
419
|
+
if (!constructor[PROPERTY_WATCHERS].has(propertyName)) {
|
|
420
|
+
constructor[PROPERTY_WATCHERS].set(propertyName, []);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
constructor[PROPERTY_WATCHERS].get(propertyName).push({
|
|
424
|
+
methodName,
|
|
425
|
+
method: descriptor.value
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
return descriptor;
|
|
430
|
+
};
|
|
259
431
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { element, customElement, property, query, queryAll } from './element';
|
|
1
|
+
export { element, customElement, property, query, queryAll, watch } 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/symbols.ts
CHANGED
|
@@ -27,4 +27,7 @@ export const CLEANUP = getSymbol('cleanup');
|
|
|
27
27
|
|
|
28
28
|
// Property symbols
|
|
29
29
|
export const PROPERTIES = getSymbol('properties');
|
|
30
|
-
export const PROPERTY_VALUES = getSymbol('property-values');
|
|
30
|
+
export const PROPERTY_VALUES = getSymbol('property-values');
|
|
31
|
+
export const PROPERTIES_INITIALIZED = getSymbol('properties-initialized');
|
|
32
|
+
export const PROPERTY_WATCHERS = getSymbol('property-watchers');
|
|
33
|
+
export const EXPLICITLY_SET_PROPERTIES = getSymbol('explicitly-set-properties');
|