snice 1.3.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 +97 -0
- package/package.json +2 -2
- package/src/element.ts +177 -5
- package/src/index.ts +1 -1
- package/src/symbols.ts +4 -1
- package/src/route-parser.d.ts +0 -8
package/README.md
CHANGED
|
@@ -36,6 +36,7 @@ Snice provides a clear separation of concerns through decorators:
|
|
|
36
36
|
- **`@property`** - Declares properties that can reflect to attributes
|
|
37
37
|
- **`@query`** - Queries a single element from shadow DOM
|
|
38
38
|
- **`@queryAll`** - Queries multiple elements from shadow DOM
|
|
39
|
+
- **`@watch`** - Watches property changes and calls a method when they occur
|
|
39
40
|
|
|
40
41
|
### Event Decorators
|
|
41
42
|
- **`@on`** - Listens for events on elements
|
|
@@ -155,6 +156,101 @@ Use it with attributes:
|
|
|
155
156
|
```
|
|
156
157
|
|
|
157
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
|
+
|
|
158
254
|
## Queries
|
|
159
255
|
|
|
160
256
|
Query single elements with `@query`:
|
|
@@ -578,6 +674,7 @@ Use the same card with different controllers:
|
|
|
578
674
|
| `@property(options)` | Declares a property that can reflect to attributes | `@property({ type: Boolean, reflect: true })` |
|
|
579
675
|
| `@query(selector)` | Queries a single element from shadow DOM | `@query('.button')` |
|
|
580
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('*')` |
|
|
581
678
|
|
|
582
679
|
### Event Decorators
|
|
583
680
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
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",
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
"@vitest/ui": "^1.0.0",
|
|
46
46
|
"happy-dom": "^12.0.0",
|
|
47
47
|
"semantic-release": "^24.2.7",
|
|
48
|
-
"typescript": "^5.
|
|
48
|
+
"typescript": "^5.9.2",
|
|
49
49
|
"vite": "^5.0.10",
|
|
50
50
|
"vitest": "^1.0.0"
|
|
51
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');
|