snice 1.8.0 → 1.9.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
@@ -43,7 +43,8 @@ Snice provides a clear separation of concerns through decorators:
43
43
  ### Event Decorators
44
44
  - **`@on`** - Listens for events on elements
45
45
  - **`@dispatch`** - Dispatches custom events after method execution
46
- - **`@channel`** - Enables bidirectional communication between elements and controllers
46
+ - **`@request`** - Makes requests from elements or controllers
47
+ - **`@response`** - Responds to requests in elements or controllers
47
48
 
48
49
  This separation keeps your components focused: elements handle presentation, controllers manage data, and pages define navigation.
49
50
 
@@ -282,13 +283,26 @@ import { element, on } from 'snice';
282
283
  @element('my-clicker')
283
284
  class MyClicker extends HTMLElement {
284
285
  html() {
285
- return `<button>Click me</button>`;
286
+ return `
287
+ <button>Click me</button>
288
+ <input type="text" placeholder="Press Enter" />
289
+ `;
286
290
  }
287
291
 
288
292
  @on('click', 'button')
289
293
  handleClick() {
290
294
  console.log('Button clicked!');
291
295
  }
296
+
297
+ @on('keydown:Enter', 'input') // Only plain Enter (no modifiers)
298
+ handleEnter() {
299
+ console.log('Enter pressed!');
300
+ }
301
+
302
+ @on('keydown:ctrl+Enter', 'input') // Only Ctrl+Enter
303
+ handleCtrlEnter() {
304
+ console.log('Ctrl + Enter pressed!');
305
+ }
292
306
  }
293
307
  ```
294
308
 
@@ -547,16 +561,16 @@ Use it:
547
561
  <user-list controller="user-controller"></user-list>
548
562
  ```
549
563
 
550
- ## Channels
564
+ ## Request/Response
551
565
 
552
566
  Bidirectional communication between elements and controllers:
553
567
 
554
568
  ```typescript
555
- // Element sends request, controller responds
569
+ // Element makes request, controller responds
556
570
  @element('user-profile')
557
571
  class UserProfile extends HTMLElement {
558
572
 
559
- @channel('fetch-user')
573
+ @request('fetch-user')
560
574
  async *getUser() {
561
575
  const user = await (yield { userId: 123 });
562
576
  return user;
@@ -573,7 +587,7 @@ class UserProfile extends HTMLElement {
573
587
  @controller('user-controller')
574
588
  class UserController {
575
589
 
576
- @channel('fetch-user')
590
+ @response('fetch-user')
577
591
  async handleFetchUser(request: { userId: number }) {
578
592
  const response = await fetch(`/api/users/${request.userId}`);
579
593
  return response.json();
@@ -714,13 +728,51 @@ The `@context` decorator:
714
728
 
715
729
  **Remember:** With great power comes great responsibility. Global state is dangerous - use it wisely and sparingly.
716
730
 
731
+ ## Observing External Changes
732
+
733
+ The `@observe` decorator provides lifecycle-managed observation of external changes like viewport intersection, element resize, media queries, and DOM mutations:
734
+
735
+ ```typescript
736
+ import { element, observe } from 'snice';
737
+
738
+ @element('lazy-image')
739
+ class LazyImage extends HTMLElement {
740
+ html() {
741
+ return `
742
+ <img data-src="photo.jpg" class="lazy" />
743
+ <div class="loading">Loading...</div>
744
+ `;
745
+ }
746
+
747
+ // Observe when image enters viewport
748
+ @observe('intersection', '.lazy', { threshold: 0.1 })
749
+ loadImage(entry: IntersectionObserverEntry) {
750
+ if (entry.isIntersecting) {
751
+ const img = entry.target as HTMLImageElement;
752
+ img.src = img.dataset.src!;
753
+ img.classList.add('loaded');
754
+ return false; // Stop observing after loading
755
+ }
756
+ }
757
+
758
+ // Respond to viewport size changes
759
+ @observe('media:(min-width: 768px)')
760
+ handleDesktop(matches: boolean) {
761
+ this.classList.toggle('desktop-mode', matches);
762
+ }
763
+ }
764
+ ```
765
+
766
+ All observers are automatically cleaned up when elements disconnect from the DOM. See the [Observe API documentation](./docs/observe.md) for more examples.
767
+
717
768
  ## Documentation
718
769
 
719
770
  - [Elements API](./docs/elements.md) - Complete guide to creating elements with properties, queries, and styling
720
771
  - [Controllers API](./docs/controllers.md) - Data fetching, business logic, and controller patterns
721
772
  - [Events API](./docs/events.md) - Event handling, dispatching, and custom events
722
- - [Channels API](./docs/channels.md) - Bidirectional communication between elements and controllers
773
+ - [Request/Response API](./docs/request-response.md) - Bidirectional communication between elements and controllers
723
774
  - [Routing API](./docs/routing.md) - Single-page application routing with transitions
775
+ - [Observe API](./docs/observe.md) - Lifecycle-managed observers for external changes
724
776
  - [Migration Guide](./docs/migration-guide.md) - Migrating from React, Vue, Angular, and other frameworks
725
777
 
726
778
  ## License
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "snice",
3
- "version": "1.8.0",
3
+ "version": "1.9.0",
4
4
  "type": "module",
5
5
  "description": "A TypeScript library",
6
6
  "main": "src/index.ts",
package/src/controller.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { setupEventHandlers, cleanupEventHandlers } from './events';
2
- import { setupChannelHandlers, cleanupChannelHandlers } from './channel';
2
+ import { setupObservers, cleanupObservers } from './observe';
3
+ import { setupResponseHandlers, cleanupResponseHandlers } from './request-response';
3
4
  import { IS_CONTROLLER_CLASS, IS_CONTROLLER_INSTANCE, CONTROLLER_KEY, CONTROLLER_NAME_KEY, CONTROLLER_ID, CONTROLLER_OPERATIONS, NATIVE_CONTROLLER, IS_ELEMENT_CLASS, ROUTER_CONTEXT } from './symbols';
4
5
  import { snice } from './global';
5
6
 
@@ -134,8 +135,11 @@ export async function attachController(element: HTMLElement, controllerName: str
134
135
  // Setup @on event handlers for controller
135
136
  setupEventHandlers(controllerInstance, element);
136
137
 
138
+ // Setup @observe observers for controller
139
+ setupObservers(controllerInstance, element);
140
+
137
141
  // Setup @channel handlers for controller
138
- setupChannelHandlers(controllerInstance, element);
142
+ setupResponseHandlers(controllerInstance, element);
139
143
 
140
144
  element.dispatchEvent(new CustomEvent('controller.attached', {
141
145
  detail: { name: controllerName, controller: controllerInstance }
@@ -169,8 +173,11 @@ export async function detachController(element: HTMLElement): Promise<void> {
169
173
  // Cleanup @on event handlers for controller
170
174
  cleanupEventHandlers(controllerInstance);
171
175
 
176
+ // Cleanup @observe observers for controller
177
+ cleanupObservers(controllerInstance);
178
+
172
179
  // Cleanup @channel handlers for controller
173
- cleanupChannelHandlers(controllerInstance);
180
+ cleanupResponseHandlers(controllerInstance);
174
181
 
175
182
  // Cleanup the controller scope
176
183
  if (scope) {
package/src/element.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import { attachController, detachController } from './controller';
2
2
  import { setupEventHandlers, cleanupEventHandlers } from './events';
3
+ import { setupObservers, cleanupObservers } from './observe';
4
+ import { setupResponseHandlers, cleanupResponseHandlers } from './request-response';
3
5
  import { IS_ELEMENT_CLASS, IS_CONTROLLER_INSTANCE, READY_PROMISE, READY_RESOLVE, CONTROLLER, PROPERTIES, PROPERTY_VALUES, PROPERTIES_INITIALIZED, PROPERTY_WATCHERS, EXPLICITLY_SET_PROPERTIES, ROUTER_CONTEXT, READY_HANDLERS, DISPOSE_HANDLERS } from './symbols';
4
6
 
5
7
  /**
@@ -21,15 +23,13 @@ export function applyElementFunctionality(constructor: any) {
21
23
  observedAttributes.push('controller');
22
24
  }
23
25
 
24
- // Add all reflected properties to observed attributes
26
+ // Add all properties to observed attributes (not just reflected ones)
25
27
  const properties = constructor[PROPERTIES];
26
28
  if (properties) {
27
29
  for (const [propName, propOptions] of properties) {
28
- if (propOptions.reflect) {
29
- const attributeName = typeof propOptions.attribute === 'string' ? propOptions.attribute : propName;
30
- if (!observedAttributes.includes(attributeName)) {
31
- observedAttributes.push(attributeName);
32
- }
30
+ const attributeName = typeof propOptions.attribute === 'string' ? propOptions.attribute : propName;
31
+ if (!observedAttributes.includes(attributeName)) {
32
+ observedAttributes.push(attributeName);
33
33
  }
34
34
  }
35
35
  }
@@ -92,26 +92,29 @@ export function applyElementFunctionality(constructor: any) {
92
92
  const properties = constructor[PROPERTIES];
93
93
  if (properties) {
94
94
  for (const [propName, propOptions] of properties) {
95
- if (propOptions.reflect) {
96
- const attributeName = typeof propOptions.attribute === 'string' ? propOptions.attribute : propName;
97
- // Only read from attribute if property hasn't been set yet
98
- if (this.hasAttribute(attributeName) && !(propName in (this[PROPERTY_VALUES] || {}))) {
99
- // Attribute exists, parse and set the property value
100
- const attrValue = this.getAttribute(attributeName);
101
-
102
- // Mark as explicitly set since it came from an attribute
103
- if (!this[EXPLICITLY_SET_PROPERTIES]) {
104
- this[EXPLICITLY_SET_PROPERTIES] = new Set();
105
- }
106
- this[EXPLICITLY_SET_PROPERTIES].add(propName);
107
-
108
- if (propOptions.type === Boolean) {
95
+ // If attribute exists, it always wins
96
+ if (this.hasAttribute(propName)) {
97
+ // Attribute exists, parse and set the property value
98
+ const attrValue = this.getAttribute(propName);
99
+
100
+ // Mark as explicitly set since it came from an attribute
101
+ if (!this[EXPLICITLY_SET_PROPERTIES]) {
102
+ this[EXPLICITLY_SET_PROPERTIES] = new Set();
103
+ }
104
+ this[EXPLICITLY_SET_PROPERTIES].add(propName);
105
+
106
+ switch (propOptions.type) {
107
+ case Boolean:
109
108
  this[propName] = attrValue !== null && attrValue !== 'false';
110
- } else if (propOptions.type === Number) {
109
+ break;
110
+ case Number:
111
111
  this[propName] = Number(attrValue);
112
- } else {
112
+ break;
113
+ case String:
114
+ this[propName] = attrValue;
115
+ break;
116
+ default:
113
117
  this[propName] = attrValue;
114
- }
115
118
  }
116
119
  }
117
120
  }
@@ -194,6 +197,16 @@ export function applyElementFunctionality(constructor: any) {
194
197
  // Setup @on event handlers - use element for host events, shadow root for delegated events
195
198
  setupEventHandlers(this, this);
196
199
 
200
+ // Setup @response handlers for elements
201
+ setupResponseHandlers(this, this);
202
+
203
+ // Setup @observe observers
204
+ try {
205
+ setupObservers(this, this);
206
+ } catch (error) {
207
+ console.error(`Error setting up observers for ${this.tagName}:`, error);
208
+ }
209
+
197
210
  // Call @ready handlers after everything is set up
198
211
  const readyHandlers = constructor[READY_HANDLERS];
199
212
  if (readyHandlers) {
@@ -238,6 +251,10 @@ export function applyElementFunctionality(constructor: any) {
238
251
  }
239
252
  // Cleanup @on event handlers
240
253
  cleanupEventHandlers(this);
254
+ // Cleanup @response handlers
255
+ cleanupResponseHandlers(this);
256
+ // Cleanup @observe observers
257
+ cleanupObservers(this);
241
258
  };
242
259
 
243
260
  constructor.prototype.attributeChangedCallback = function(name: string, oldValue: string, newValue: string) {
@@ -245,43 +262,41 @@ export function applyElementFunctionality(constructor: any) {
245
262
  if (name === 'controller') {
246
263
  this.controller = newValue;
247
264
  } else {
248
- // Handle reflected properties
265
+ // Handle all properties (not just reflected ones)
249
266
  const properties = constructor[PROPERTIES];
250
267
  if (properties) {
251
268
  for (const [propName, propOptions] of properties) {
252
- if (propOptions.reflect) {
253
- const attributeName = typeof propOptions.attribute === 'string' ? propOptions.attribute : propName;
254
- if (attributeName === name) {
255
- // Check if the current property value already matches to avoid feedback loops
256
- const currentValue = this[PROPERTY_VALUES]?.[propName];
257
-
258
- // Parse the new value based on type
259
- let parsedValue: any;
260
- if (propOptions.type === Boolean) {
261
- parsedValue = newValue !== null && newValue !== 'false';
262
- } else if (propOptions.type === Number) {
269
+ const attributeName = typeof propOptions.attribute === 'string' ? propOptions.attribute : propName;
270
+ if (attributeName === name) {
271
+ // Check if the current property value already matches to avoid feedback loops
272
+ const currentValue = this[PROPERTY_VALUES]?.[propName];
273
+
274
+ // Parse the new value based on type
275
+ let parsedValue: any;
276
+ if (propOptions.type === Boolean) {
277
+ parsedValue = newValue !== null && newValue !== 'false';
278
+ } else if (propOptions.type === Number) {
279
+ parsedValue = Number(newValue);
280
+ } else {
281
+ // If no type specified, try to infer from current value type
282
+ if (typeof currentValue === 'number' && newValue !== null) {
263
283
  parsedValue = Number(newValue);
264
284
  } else {
265
- // If no type specified, try to infer from current value type
266
- if (typeof currentValue === 'number' && newValue !== null) {
267
- parsedValue = Number(newValue);
268
- } else {
269
- parsedValue = newValue;
270
- }
285
+ parsedValue = newValue;
271
286
  }
272
-
273
- // Only update if the value actually changed
274
- if (currentValue !== parsedValue) {
275
- // Mark as explicitly set since it came from an attribute change
276
- if (!this[EXPLICITLY_SET_PROPERTIES]) {
277
- this[EXPLICITLY_SET_PROPERTIES] = new Set();
278
- }
279
- this[EXPLICITLY_SET_PROPERTIES].add(propName);
280
-
281
- this[propName] = parsedValue;
287
+ }
288
+
289
+ // Only update if the value actually changed
290
+ if (currentValue !== parsedValue) {
291
+ // Mark as explicitly set since it came from an attribute change
292
+ if (!this[EXPLICITLY_SET_PROPERTIES]) {
293
+ this[EXPLICITLY_SET_PROPERTIES] = new Set();
282
294
  }
283
- break;
295
+ this[EXPLICITLY_SET_PROPERTIES].add(propName);
296
+
297
+ this[propName] = parsedValue;
284
298
  }
299
+ break;
285
300
  }
286
301
  }
287
302
  }
@@ -522,8 +537,9 @@ export function context() {
522
537
  });
523
538
 
524
539
  // Dispatch event and wait for response
525
- // For controllers, use their element. For elements, dispatch on the host
526
- let targetElement = this.element || this;
540
+ // Check if this is a controller using the symbol
541
+ const isController = this[IS_CONTROLLER_INSTANCE] === true;
542
+ let targetElement = isController && this.element ? this.element : this;
527
543
 
528
544
  // If element is null (e.g., controller was detached), can't get context
529
545
  if (!targetElement || !targetElement.dispatchEvent) {