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 +59 -7
- package/package.json +1 -1
- package/src/controller.ts +10 -3
- package/src/element.ts +70 -54
- package/src/events.ts +248 -45
- package/src/index.ts +4 -2
- package/src/observe.ts +414 -0
- package/src/request-response.ts +188 -0
- package/src/symbols.ts +4 -1
- package/src/channel.ts +0 -181
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
|
-
- **`@
|
|
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
|
|
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
|
-
##
|
|
564
|
+
## Request/Response
|
|
551
565
|
|
|
552
566
|
Bidirectional communication between elements and controllers:
|
|
553
567
|
|
|
554
568
|
```typescript
|
|
555
|
-
// Element
|
|
569
|
+
// Element makes request, controller responds
|
|
556
570
|
@element('user-profile')
|
|
557
571
|
class UserProfile extends HTMLElement {
|
|
558
572
|
|
|
559
|
-
@
|
|
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
|
-
@
|
|
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
|
-
- [
|
|
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
package/src/controller.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { setupEventHandlers, cleanupEventHandlers } from './events';
|
|
2
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
//
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
109
|
+
break;
|
|
110
|
+
case Number:
|
|
111
111
|
this[propName] = Number(attrValue);
|
|
112
|
-
|
|
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
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
if
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
|
|
266
|
-
if (typeof currentValue === 'number' && newValue !== null) {
|
|
267
|
-
parsedValue = Number(newValue);
|
|
268
|
-
} else {
|
|
269
|
-
parsedValue = newValue;
|
|
270
|
-
}
|
|
285
|
+
parsedValue = newValue;
|
|
271
286
|
}
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
526
|
-
|
|
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) {
|