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 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.0",
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.3.3",
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
- if (options?.reflect && this.setAttribute) {
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(options.attribute || propertyKey);
311
+ this.removeAttribute(attributeName);
194
312
  } else {
195
- this.setAttribute(options.attribute || propertyKey, String(value));
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');
@@ -1,8 +0,0 @@
1
- declare module 'route-parser' {
2
- export default class Route {
3
- constructor(spec: string);
4
- match(path: string): Record<string, string> | false;
5
- reverse(params?: Record<string, any>): string;
6
- spec: string;
7
- }
8
- }