snice 1.0.0 → 1.2.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 +45 -8
- package/package.json +7 -5
- package/src/channel.ts +181 -0
- package/src/controller.ts +329 -0
- package/src/element.ts +259 -0
- package/src/events.ts +146 -0
- package/src/global.ts +31 -0
- package/src/index.ts +10 -0
- package/src/router.ts +386 -0
- package/src/symbols.ts +30 -0
- package/dist/snice.js +0 -980
- package/dist/snice.umd.cjs +0 -8
package/README.md
CHANGED
|
@@ -23,7 +23,7 @@ Snice provides a clear separation of concerns through decorators:
|
|
|
23
23
|
- **`@page`** - Sets up routing and navigation between different views
|
|
24
24
|
|
|
25
25
|
### Property & Query Decorators
|
|
26
|
-
- **`@property`** - Declares
|
|
26
|
+
- **`@property`** - Declares properties that can reflect to attributes
|
|
27
27
|
- **`@query`** - Queries a single element from shadow DOM
|
|
28
28
|
- **`@queryAll`** - Queries multiple elements from shadow DOM
|
|
29
29
|
|
|
@@ -219,7 +219,7 @@ class MyClicker extends HTMLElement {
|
|
|
219
219
|
Automatically dispatch custom events with `@dispatch`:
|
|
220
220
|
|
|
221
221
|
```typescript
|
|
222
|
-
import { element, dispatch, on } from 'snice';
|
|
222
|
+
import { element, dispatch, on, query } from 'snice';
|
|
223
223
|
|
|
224
224
|
@element('toggle-switch')
|
|
225
225
|
class ToggleSwitch extends HTMLElement {
|
|
@@ -315,11 +315,22 @@ class AboutPage extends HTMLElement {
|
|
|
315
315
|
}
|
|
316
316
|
}
|
|
317
317
|
|
|
318
|
+
// Page with URL parameter
|
|
319
|
+
@page({ tag: 'user-page', routes: ['/users/:id'] })
|
|
320
|
+
class UserPage extends HTMLElement {
|
|
321
|
+
id = ''; // Automatically set from URL
|
|
322
|
+
|
|
323
|
+
html() {
|
|
324
|
+
return `<h1>User ${this.id}</h1>`;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
318
328
|
// Start the router
|
|
319
329
|
initialize();
|
|
320
330
|
|
|
321
331
|
// Navigate programmatically
|
|
322
332
|
navigate('/about');
|
|
333
|
+
navigate('/users/123'); // Sets id="123" on UserPage
|
|
323
334
|
```
|
|
324
335
|
|
|
325
336
|
## Controllers (Data Fetching)
|
|
@@ -339,7 +350,7 @@ class UserController {
|
|
|
339
350
|
(element as any).setUsers(users);
|
|
340
351
|
}
|
|
341
352
|
|
|
342
|
-
async detach() {
|
|
353
|
+
async detach(element: HTMLElement) {
|
|
343
354
|
// Cleanup
|
|
344
355
|
}
|
|
345
356
|
}
|
|
@@ -358,7 +369,9 @@ class UserList extends HTMLElement {
|
|
|
358
369
|
|
|
359
370
|
setUsers(users: any[]) {
|
|
360
371
|
this.users = users;
|
|
361
|
-
|
|
372
|
+
if (this.shadowRoot) {
|
|
373
|
+
this.shadowRoot.innerHTML = this.html();
|
|
374
|
+
}
|
|
362
375
|
}
|
|
363
376
|
}
|
|
364
377
|
```
|
|
@@ -393,6 +406,11 @@ class UserCard extends HTMLElement {
|
|
|
393
406
|
// --- controllers/user-controller.ts
|
|
394
407
|
@controller('user-controller')
|
|
395
408
|
class UserController {
|
|
409
|
+
element: HTMLElement | null = null;
|
|
410
|
+
|
|
411
|
+
async attach(element: HTMLElement) {}
|
|
412
|
+
async detach(element: HTMLElement) {}
|
|
413
|
+
|
|
396
414
|
@channel('get-data')
|
|
397
415
|
handleGetData(request) {
|
|
398
416
|
console.log(request); // { id: 123 }
|
|
@@ -491,7 +509,6 @@ class WeatherController {
|
|
|
491
509
|
element: HTMLElement | null = null;
|
|
492
510
|
|
|
493
511
|
async attach(element: HTMLElement) {
|
|
494
|
-
this.element = element;
|
|
495
512
|
|
|
496
513
|
// Simulate fetching weather data
|
|
497
514
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
@@ -512,6 +529,10 @@ class WeatherController {
|
|
|
512
529
|
`);
|
|
513
530
|
(element as any).setFooter('Updated just now');
|
|
514
531
|
}
|
|
532
|
+
|
|
533
|
+
async detach(element: HTMLElement) {
|
|
534
|
+
// Cleanup if needed
|
|
535
|
+
}
|
|
515
536
|
}
|
|
516
537
|
```
|
|
517
538
|
|
|
@@ -557,9 +578,16 @@ Use the same card with different controllers:
|
|
|
557
578
|
|
|
558
579
|
```typescript
|
|
559
580
|
interface PropertyOptions {
|
|
560
|
-
type?: typeof String | typeof Number | typeof Boolean; // Type converter
|
|
561
|
-
reflect?: boolean;
|
|
562
|
-
attribute?: string;
|
|
581
|
+
type?: typeof String | typeof Number | typeof Boolean | typeof Array | typeof Object; // Type converter
|
|
582
|
+
reflect?: boolean; // Reflect property to attribute
|
|
583
|
+
attribute?: string | boolean; // Custom attribute name or false to disable
|
|
584
|
+
converter?: PropertyConverter; // Custom converter
|
|
585
|
+
hasChanged?: (value: any, oldValue: any) => boolean; // Custom change detector
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
interface PropertyConverter {
|
|
589
|
+
fromAttribute?(value: string | null, type?: any): any;
|
|
590
|
+
toAttribute?(value: any, type?: any): string | null;
|
|
563
591
|
}
|
|
564
592
|
```
|
|
565
593
|
|
|
@@ -571,6 +599,15 @@ interface DispatchOptions extends EventInit {
|
|
|
571
599
|
}
|
|
572
600
|
```
|
|
573
601
|
|
|
602
|
+
## Documentation
|
|
603
|
+
|
|
604
|
+
- [Elements API](./docs/elements.md) - Complete guide to creating elements with properties, queries, and styling
|
|
605
|
+
- [Controllers API](./docs/controllers.md) - Data fetching, business logic, and controller patterns
|
|
606
|
+
- [Events API](./docs/events.md) - Event handling, dispatching, and custom events
|
|
607
|
+
- [Channels API](./docs/channels.md) - Bidirectional communication between elements and controllers
|
|
608
|
+
- [Routing API](./docs/routing.md) - Single-page application routing with transitions
|
|
609
|
+
- [Migration Guide](./docs/migration-guide.md) - Migrating from React, Vue, Angular, and other frameworks
|
|
610
|
+
|
|
574
611
|
## License
|
|
575
612
|
|
|
576
613
|
MIT
|
package/package.json
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "snice",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "A TypeScript library",
|
|
6
|
-
"main": "
|
|
7
|
-
"module": "
|
|
8
|
-
"types": "
|
|
6
|
+
"main": "src/index.ts",
|
|
7
|
+
"module": "src/index.ts",
|
|
8
|
+
"types": "src/index.ts",
|
|
9
9
|
"files": [
|
|
10
|
-
"
|
|
10
|
+
"src",
|
|
11
|
+
"!src/**/*.test.ts",
|
|
12
|
+
"!src/**/*.spec.ts"
|
|
11
13
|
],
|
|
12
14
|
"publishConfig": {
|
|
13
15
|
"access": "public"
|
package/src/channel.ts
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { CHANNEL_HANDLERS, CLEANUP } from './symbols';
|
|
2
|
+
|
|
3
|
+
export interface ChannelOptions extends EventInit {
|
|
4
|
+
/**
|
|
5
|
+
* Timeout for waiting for responses (in ms)
|
|
6
|
+
*/
|
|
7
|
+
timeout?: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Decorator for bidirectional communication channels.
|
|
12
|
+
* On elements: Opens a channel using async generator
|
|
13
|
+
* On controllers: Responds to channel requests
|
|
14
|
+
*
|
|
15
|
+
* @param channelName The name of the channel
|
|
16
|
+
* @param options Optional configuration
|
|
17
|
+
*/
|
|
18
|
+
export function channel(channelName: string, options?: ChannelOptions) {
|
|
19
|
+
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
|
|
20
|
+
const originalMethod = descriptor.value;
|
|
21
|
+
|
|
22
|
+
// We'll determine at runtime whether this is element or controller
|
|
23
|
+
// by checking if 'this' is an HTMLElement
|
|
24
|
+
descriptor.value = async function (this: any, ...args: any[]) {
|
|
25
|
+
// Runtime check: if 'this' is an HTMLElement, it's element-side
|
|
26
|
+
if (this instanceof HTMLElement) {
|
|
27
|
+
// Element side - handle async generator
|
|
28
|
+
const timeout = options?.timeout ?? 100; // Default 100ms timeout
|
|
29
|
+
|
|
30
|
+
// Create the generator
|
|
31
|
+
const generator = originalMethod.apply(this, args);
|
|
32
|
+
|
|
33
|
+
// Get the first yield (the request payload)
|
|
34
|
+
const { value: payload, done } = await generator.next();
|
|
35
|
+
|
|
36
|
+
if (done) {
|
|
37
|
+
// Generator returned without yielding
|
|
38
|
+
return payload;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Create data promise and expose resolve/reject
|
|
42
|
+
let dataResolve: (value: any) => void;
|
|
43
|
+
let dataReject: (reason?: any) => void;
|
|
44
|
+
const dataPromise = new Promise((resolve, reject) => {
|
|
45
|
+
dataResolve = resolve;
|
|
46
|
+
dataReject = reject;
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// Create timeout promise and expose resolve/reject
|
|
50
|
+
let timeoutResolve: () => void;
|
|
51
|
+
let timeoutReject: (reason?: any) => void;
|
|
52
|
+
let timeoutId: NodeJS.Timeout;
|
|
53
|
+
const timeoutPromise = new Promise<void>((resolve, reject) => {
|
|
54
|
+
timeoutResolve = resolve;
|
|
55
|
+
timeoutReject = reject;
|
|
56
|
+
timeoutId = setTimeout(() => {
|
|
57
|
+
reject(new Error(`Channel timeout after ${timeout}ms`));
|
|
58
|
+
}, timeout);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// Dispatch event with promises
|
|
62
|
+
const eventName = `@channel:${channelName}`;
|
|
63
|
+
const event = new CustomEvent(eventName, {
|
|
64
|
+
bubbles: options?.bubbles !== undefined ? options.bubbles : true,
|
|
65
|
+
cancelable: options?.cancelable || false,
|
|
66
|
+
composed: true, // Allow crossing shadow DOM boundaries
|
|
67
|
+
detail: {
|
|
68
|
+
payload,
|
|
69
|
+
timeout: {
|
|
70
|
+
resolve: () => {
|
|
71
|
+
clearTimeout(timeoutId);
|
|
72
|
+
timeoutResolve();
|
|
73
|
+
},
|
|
74
|
+
reject: timeoutReject!
|
|
75
|
+
},
|
|
76
|
+
data: {
|
|
77
|
+
resolve: dataResolve!,
|
|
78
|
+
reject: dataReject!
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
this.dispatchEvent(event);
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
// Wait for timeout to be resolved or rejected
|
|
87
|
+
await timeoutPromise;
|
|
88
|
+
// If we get here, controller responded in time
|
|
89
|
+
const response = await dataPromise;
|
|
90
|
+
|
|
91
|
+
// Send response back to generator and get final return value
|
|
92
|
+
const { value: finalValue } = await generator.next(response);
|
|
93
|
+
return finalValue;
|
|
94
|
+
} catch (error) {
|
|
95
|
+
// Send error to generator
|
|
96
|
+
try {
|
|
97
|
+
await generator.throw(error);
|
|
98
|
+
} catch (generatorError) {
|
|
99
|
+
throw generatorError;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
} else {
|
|
103
|
+
// Controller side - just call the original method
|
|
104
|
+
// The actual channel setup happens in setupChannelHandlers
|
|
105
|
+
return originalMethod.apply(this, args);
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
// Store channel metadata on the prototype for controllers
|
|
110
|
+
// This will be picked up by setupChannelHandlers
|
|
111
|
+
if (!target[CHANNEL_HANDLERS]) {
|
|
112
|
+
target[CHANNEL_HANDLERS] = [];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
target[CHANNEL_HANDLERS].push({
|
|
116
|
+
channelName,
|
|
117
|
+
methodName: propertyKey,
|
|
118
|
+
method: originalMethod
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
return descriptor;
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Helper to setup channel handlers for controllers
|
|
126
|
+
export function setupChannelHandlers(instance: any, element: HTMLElement) {
|
|
127
|
+
const handlers = instance.constructor.prototype[CHANNEL_HANDLERS];
|
|
128
|
+
if (!handlers) return;
|
|
129
|
+
|
|
130
|
+
// Store cleanup functions
|
|
131
|
+
// Initialize cleanup object if needed
|
|
132
|
+
if (!instance[CLEANUP]) {
|
|
133
|
+
instance[CLEANUP] = { events: [], channels: [] };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
for (const handler of handlers) {
|
|
137
|
+
const boundMethod = handler.method.bind(instance);
|
|
138
|
+
const eventName = `@channel:${handler.channelName}`;
|
|
139
|
+
|
|
140
|
+
// Setup channel handler
|
|
141
|
+
const channelHandler = (event: CustomEvent) => {
|
|
142
|
+
// Extract promises and payload
|
|
143
|
+
const { data, timeout, payload } = event.detail;
|
|
144
|
+
|
|
145
|
+
// Prevent other controllers from responding
|
|
146
|
+
event.preventDefault();
|
|
147
|
+
event.stopImmediatePropagation();
|
|
148
|
+
event.stopPropagation();
|
|
149
|
+
|
|
150
|
+
// Call the controller method and handle the result
|
|
151
|
+
Promise.resolve(boundMethod(payload))
|
|
152
|
+
.then(result => {
|
|
153
|
+
// Clear the timeout and resolve the data promise
|
|
154
|
+
timeout.resolve();
|
|
155
|
+
data.resolve(result);
|
|
156
|
+
})
|
|
157
|
+
.catch(error => {
|
|
158
|
+
// Clear timeout and reject the data promise on error
|
|
159
|
+
timeout.resolve();
|
|
160
|
+
data.reject(error);
|
|
161
|
+
console.error(`Error in channel handler ${handler.methodName}:`, error);
|
|
162
|
+
});
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
element.addEventListener(eventName, channelHandler as EventListener);
|
|
166
|
+
|
|
167
|
+
instance[CLEANUP].channels.push(() => {
|
|
168
|
+
element.removeEventListener(eventName, channelHandler as EventListener);
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Helper to cleanup channel handlers
|
|
174
|
+
export function cleanupChannelHandlers(instance: any) {
|
|
175
|
+
if (instance[CLEANUP]?.channels) {
|
|
176
|
+
for (const cleanup of instance[CLEANUP].channels) {
|
|
177
|
+
cleanup();
|
|
178
|
+
}
|
|
179
|
+
instance[CLEANUP].channels = [];
|
|
180
|
+
}
|
|
181
|
+
}
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
import { setupEventHandlers, cleanupEventHandlers } from './events';
|
|
2
|
+
import { setupChannelHandlers, cleanupChannelHandlers } from './channel';
|
|
3
|
+
import { IS_CONTROLLER_CLASS, CONTROLLER_KEY, CONTROLLER_NAME_KEY, CONTROLLER_ID, CONTROLLER_OPERATIONS, NATIVE_CONTROLLER, IS_ELEMENT_CLASS } from './symbols';
|
|
4
|
+
import { snice } from './global';
|
|
5
|
+
|
|
6
|
+
type Maybe<T> = T | null | undefined;
|
|
7
|
+
|
|
8
|
+
export interface IController<T extends HTMLElement = HTMLElement> {
|
|
9
|
+
element: Maybe<T>;
|
|
10
|
+
attach(element: T): void | Promise<void>;
|
|
11
|
+
detach(element: T): void | Promise<void>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type ControllerClass<T extends HTMLElement = HTMLElement> = new() => IController<T>;
|
|
15
|
+
|
|
16
|
+
// Controller-scoped cleanup registry
|
|
17
|
+
class ControllerScope {
|
|
18
|
+
private cleanupFns: Map<string, Function> = new Map();
|
|
19
|
+
private pendingOperations: Set<Promise<void>> = new Set();
|
|
20
|
+
|
|
21
|
+
register(key: string, cleanup: Function): void {
|
|
22
|
+
this.cleanupFns.set(key, cleanup);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
unregister(key: string): void {
|
|
26
|
+
this.cleanupFns.delete(key);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async cleanup(): Promise<void> {
|
|
30
|
+
// Wait for all pending operations
|
|
31
|
+
await Promise.all(this.pendingOperations);
|
|
32
|
+
|
|
33
|
+
// Run all cleanup functions
|
|
34
|
+
for (const cleanup of this.cleanupFns.values()) {
|
|
35
|
+
try {
|
|
36
|
+
await cleanup();
|
|
37
|
+
} catch (error) {
|
|
38
|
+
console.error('Error during cleanup:', error);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
this.cleanupFns.clear();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async runOperation<T>(operation: () => Promise<T>): Promise<T> {
|
|
45
|
+
const promise = operation();
|
|
46
|
+
const voidPromise = promise.then(() => {}, () => {});
|
|
47
|
+
this.pendingOperations.add(voidPromise);
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
const result = await promise;
|
|
51
|
+
this.pendingOperations.delete(voidPromise);
|
|
52
|
+
return result;
|
|
53
|
+
} catch (error) {
|
|
54
|
+
this.pendingOperations.delete(voidPromise);
|
|
55
|
+
throw error;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Decorator to register a controller class with a name
|
|
62
|
+
* @param name The name to register the controller under
|
|
63
|
+
*/
|
|
64
|
+
export function controller(name: string) {
|
|
65
|
+
return function <T extends ControllerClass>(constructor: T) {
|
|
66
|
+
snice.controllerRegistry.set(name, constructor);
|
|
67
|
+
// Mark as controller class for channel decorator detection
|
|
68
|
+
(constructor.prototype as any)[IS_CONTROLLER_CLASS] = true;
|
|
69
|
+
return constructor;
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Attaches a controller to an element
|
|
75
|
+
* @param element The element to attach the controller to
|
|
76
|
+
* @param controllerName The name of the controller to attach
|
|
77
|
+
*/
|
|
78
|
+
export async function attachController(element: HTMLElement, controllerName: string): Promise<void> {
|
|
79
|
+
const existingController = (element as any)[CONTROLLER_KEY] as IController | undefined;
|
|
80
|
+
const existingName = (element as any)[CONTROLLER_NAME_KEY] as string | undefined;
|
|
81
|
+
|
|
82
|
+
// For native elements, check if this is actually the desired controller
|
|
83
|
+
const nativeController = (element as any)[NATIVE_CONTROLLER];
|
|
84
|
+
if (nativeController !== undefined && nativeController !== controllerName) {
|
|
85
|
+
// This attachment is outdated, skip it
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (existingName === controllerName && existingController) {
|
|
90
|
+
// Already attached and controller exists
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// If there's an existing controller, detach it first
|
|
95
|
+
if (existingController) {
|
|
96
|
+
await detachController(element);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const ControllerClass = snice.controllerRegistry.get(controllerName);
|
|
100
|
+
if (!ControllerClass) {
|
|
101
|
+
throw new Error(`Controller "${controllerName}" not found in registry`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Create controller instance with unique ID and scope
|
|
105
|
+
const controllerInstance = new ControllerClass();
|
|
106
|
+
snice.controllerIdCounter += 1;
|
|
107
|
+
const controllerId = snice.controllerIdCounter;
|
|
108
|
+
const scope = new ControllerScope();
|
|
109
|
+
|
|
110
|
+
(controllerInstance as any)[CONTROLLER_ID] = controllerId;
|
|
111
|
+
controllerInstance.element = element;
|
|
112
|
+
|
|
113
|
+
// Store references
|
|
114
|
+
(element as any)[CONTROLLER_KEY] = controllerInstance;
|
|
115
|
+
(element as any)[CONTROLLER_NAME_KEY] = controllerName;
|
|
116
|
+
(element as any)[CONTROLLER_OPERATIONS] = scope;
|
|
117
|
+
|
|
118
|
+
// Wait for element to be ready (required)
|
|
119
|
+
await (element as any).ready;
|
|
120
|
+
|
|
121
|
+
// Run attach in the controller's scope
|
|
122
|
+
await scope.runOperation(async () => {
|
|
123
|
+
await controllerInstance.attach(element);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// Setup @on event handlers for controller
|
|
127
|
+
setupEventHandlers(controllerInstance, element);
|
|
128
|
+
|
|
129
|
+
// Setup @channel handlers for controller
|
|
130
|
+
setupChannelHandlers(controllerInstance, element);
|
|
131
|
+
|
|
132
|
+
element.dispatchEvent(new CustomEvent('controller.attached', {
|
|
133
|
+
detail: { name: controllerName, controller: controllerInstance }
|
|
134
|
+
}));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Detaches a controller from an element
|
|
139
|
+
* @param element The element to detach the controller from
|
|
140
|
+
*/
|
|
141
|
+
export async function detachController(element: HTMLElement): Promise<void> {
|
|
142
|
+
const controllerInstance = (element as any)[CONTROLLER_KEY] as IController | undefined;
|
|
143
|
+
const controllerName = (element as any)[CONTROLLER_NAME_KEY] as string | undefined;
|
|
144
|
+
const scope = (element as any)[CONTROLLER_OPERATIONS] as ControllerScope | undefined;
|
|
145
|
+
|
|
146
|
+
if (!controllerInstance) {
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Run detach in the controller's scope
|
|
151
|
+
if (scope) {
|
|
152
|
+
await scope.runOperation(async () => {
|
|
153
|
+
await controllerInstance.detach(element);
|
|
154
|
+
});
|
|
155
|
+
} else {
|
|
156
|
+
await controllerInstance.detach(element);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
controllerInstance.element = null;
|
|
160
|
+
|
|
161
|
+
// Cleanup @on event handlers for controller
|
|
162
|
+
cleanupEventHandlers(controllerInstance);
|
|
163
|
+
|
|
164
|
+
// Cleanup @channel handlers for controller
|
|
165
|
+
cleanupChannelHandlers(controllerInstance);
|
|
166
|
+
|
|
167
|
+
// Cleanup the controller scope
|
|
168
|
+
if (scope) {
|
|
169
|
+
await scope.cleanup();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
delete (element as any)[CONTROLLER_KEY];
|
|
173
|
+
delete (element as any)[CONTROLLER_NAME_KEY];
|
|
174
|
+
delete (element as any)[CONTROLLER_OPERATIONS];
|
|
175
|
+
|
|
176
|
+
element.dispatchEvent(new CustomEvent('controller.detached', {
|
|
177
|
+
detail: { name: controllerName, controller: controllerInstance }
|
|
178
|
+
}));
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Gets the controller instance attached to an element
|
|
183
|
+
* @param element The element to get the controller from
|
|
184
|
+
* @returns The controller instance or undefined
|
|
185
|
+
*/
|
|
186
|
+
export function getController<T extends IController = IController>(element: HTMLElement): T | undefined {
|
|
187
|
+
return (element as any)[CONTROLLER_KEY] as T | undefined;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Gets the controller scope for an element
|
|
192
|
+
* @param element The element to get the scope from
|
|
193
|
+
* @returns The controller scope or undefined
|
|
194
|
+
*/
|
|
195
|
+
export function getControllerScope(element: HTMLElement): ControllerScope | undefined {
|
|
196
|
+
return (element as any)[CONTROLLER_OPERATIONS] as ControllerScope | undefined;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Enable controller support for native HTML elements
|
|
201
|
+
* This sets up a MutationObserver to watch for controller attributes
|
|
202
|
+
* on non-custom elements (elements without hyphens in their tag names)
|
|
203
|
+
*/
|
|
204
|
+
export function useNativeElementControllers() {
|
|
205
|
+
// Return if already initialized
|
|
206
|
+
if ((globalThis as any).sniceNativeControllersInitialized) {
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
(globalThis as any).sniceNativeControllersInitialized = true;
|
|
210
|
+
|
|
211
|
+
// Process elements that already have controller attribute
|
|
212
|
+
function processElement(element: Element) {
|
|
213
|
+
if (!(element instanceof HTMLElement)) return;
|
|
214
|
+
|
|
215
|
+
// Skip custom elements (they handle controllers themselves)
|
|
216
|
+
if (element.tagName.includes('-')) return;
|
|
217
|
+
|
|
218
|
+
// Skip elements that are @element decorated (they have their own controller handling)
|
|
219
|
+
if ((element as any)[IS_ELEMENT_CLASS]) return;
|
|
220
|
+
|
|
221
|
+
const controllerName = element.getAttribute('controller');
|
|
222
|
+
const currentControllerName = (element as any)[NATIVE_CONTROLLER];
|
|
223
|
+
|
|
224
|
+
if (controllerName && controllerName !== currentControllerName) {
|
|
225
|
+
// Controller added or changed
|
|
226
|
+
(element as any)[NATIVE_CONTROLLER] = controllerName;
|
|
227
|
+
|
|
228
|
+
// For non-custom elements, we need to add the ready promise
|
|
229
|
+
if (!(element as any).ready) {
|
|
230
|
+
(element as any).ready = Promise.resolve();
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Detach old controller if exists (don't await - let it run async)
|
|
234
|
+
if (currentControllerName) {
|
|
235
|
+
detachController(element as HTMLElement).catch(error => {
|
|
236
|
+
console.error(`Failed to detach old controller from native element:`, error);
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Attach the new controller
|
|
241
|
+
attachController(element as HTMLElement, controllerName).catch(error => {
|
|
242
|
+
console.error(`Failed to attach controller "${controllerName}" to native element:`, error);
|
|
243
|
+
});
|
|
244
|
+
} else if (!controllerName && currentControllerName) {
|
|
245
|
+
// Controller was removed
|
|
246
|
+
delete (element as any)[NATIVE_CONTROLLER];
|
|
247
|
+
// Clear the controller name immediately to allow re-attachment
|
|
248
|
+
delete (element as any)[CONTROLLER_NAME_KEY];
|
|
249
|
+
detachController(element as HTMLElement).catch(error => {
|
|
250
|
+
console.error(`Failed to detach controller from native element:`, error);
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Set up MutationObserver to watch for controller attributes
|
|
256
|
+
const observer = new MutationObserver((mutations) => {
|
|
257
|
+
for (const mutation of mutations) {
|
|
258
|
+
if (mutation.type === 'attributes' && mutation.attributeName === 'controller') {
|
|
259
|
+
processElement(mutation.target as Element);
|
|
260
|
+
} else if (mutation.type === 'childList') {
|
|
261
|
+
// Process added nodes
|
|
262
|
+
mutation.addedNodes.forEach(node => {
|
|
263
|
+
if (node instanceof HTMLElement) {
|
|
264
|
+
// Process the node itself
|
|
265
|
+
processElement(node);
|
|
266
|
+
// Process all descendants with controller attribute
|
|
267
|
+
node.querySelectorAll('[controller]:not([class*="-"])').forEach(processElement);
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
// Start observing when DOM is ready
|
|
275
|
+
if (document.readyState === 'loading') {
|
|
276
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
277
|
+
// Process existing elements (excluding custom elements)
|
|
278
|
+
document.querySelectorAll('[controller]:not([class*="-"])').forEach(processElement);
|
|
279
|
+
|
|
280
|
+
// Start observing
|
|
281
|
+
observer.observe(document.body, {
|
|
282
|
+
attributes: true,
|
|
283
|
+
attributeFilter: ['controller'],
|
|
284
|
+
childList: true,
|
|
285
|
+
subtree: true
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
} else {
|
|
289
|
+
// DOM already loaded
|
|
290
|
+
document.querySelectorAll('[controller]:not([class*="-"])').forEach(processElement);
|
|
291
|
+
|
|
292
|
+
observer.observe(document.body, {
|
|
293
|
+
attributes: true,
|
|
294
|
+
attributeFilter: ['controller'],
|
|
295
|
+
childList: true,
|
|
296
|
+
subtree: true
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Store observer reference for cleanup if needed
|
|
301
|
+
(globalThis as any).sniceNativeControllerObserver = observer;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Stop watching for native element controllers
|
|
306
|
+
*/
|
|
307
|
+
export function cleanupNativeElementControllers() {
|
|
308
|
+
const observer = (globalThis as any).sniceNativeControllerObserver;
|
|
309
|
+
if (observer) {
|
|
310
|
+
observer.disconnect();
|
|
311
|
+
delete (globalThis as any).sniceNativeControllerObserver;
|
|
312
|
+
delete (globalThis as any).sniceNativeControllersInitialized;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Registers a cleanup function for the current controller
|
|
318
|
+
* @param controller The controller instance
|
|
319
|
+
* @param key A unique key for this cleanup function
|
|
320
|
+
* @param cleanup The cleanup function to register
|
|
321
|
+
*/
|
|
322
|
+
export function registerControllerCleanup(controller: IController, key: string, cleanup: Function): void {
|
|
323
|
+
if (!controller.element) return;
|
|
324
|
+
|
|
325
|
+
const scope = getControllerScope(controller.element as HTMLElement);
|
|
326
|
+
if (scope) {
|
|
327
|
+
scope.register(key, cleanup);
|
|
328
|
+
}
|
|
329
|
+
}
|