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/src/element.ts
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { attachController, detachController } from './controller';
|
|
2
|
+
import { setupEventHandlers, cleanupEventHandlers } from './events';
|
|
3
|
+
import { IS_ELEMENT_CLASS, READY_PROMISE, READY_RESOLVE, CONTROLLER, PROPERTIES, PROPERTY_VALUES } from './symbols';
|
|
4
|
+
|
|
5
|
+
export function element(tagName: string) {
|
|
6
|
+
return function (constructor: any) {
|
|
7
|
+
// Mark as element class for channel decorator detection
|
|
8
|
+
(constructor.prototype as any)[IS_ELEMENT_CLASS] = true;
|
|
9
|
+
|
|
10
|
+
// Add controller property to all elements
|
|
11
|
+
const originalConnectedCallback = constructor.prototype.connectedCallback;
|
|
12
|
+
const originalDisconnectedCallback = constructor.prototype.disconnectedCallback;
|
|
13
|
+
const originalAttributeChangedCallback = constructor.prototype.attributeChangedCallback;
|
|
14
|
+
|
|
15
|
+
// Add 'controller' to observed attributes
|
|
16
|
+
const observedAttributes = constructor.observedAttributes || [];
|
|
17
|
+
if (!observedAttributes.includes('controller')) {
|
|
18
|
+
observedAttributes.push('controller');
|
|
19
|
+
}
|
|
20
|
+
Object.defineProperty(constructor, 'observedAttributes', {
|
|
21
|
+
get() { return observedAttributes; },
|
|
22
|
+
configurable: true
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
// Add ready property - always returns a promise
|
|
26
|
+
Object.defineProperty(constructor.prototype, 'ready', {
|
|
27
|
+
get() {
|
|
28
|
+
if (!this[READY_PROMISE]) {
|
|
29
|
+
// Create a pending promise if not yet initialized
|
|
30
|
+
this[READY_PROMISE] = new Promise<void>((resolve) => {
|
|
31
|
+
this[READY_RESOLVE] = resolve;
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
return this[READY_PROMISE];
|
|
35
|
+
},
|
|
36
|
+
enumerable: true,
|
|
37
|
+
configurable: true
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// Add controller property
|
|
41
|
+
Object.defineProperty(constructor.prototype, 'controller', {
|
|
42
|
+
get() {
|
|
43
|
+
return this[CONTROLLER];
|
|
44
|
+
},
|
|
45
|
+
set(value: string) {
|
|
46
|
+
const oldValue = this[CONTROLLER];
|
|
47
|
+
this[CONTROLLER] = value;
|
|
48
|
+
if (value !== oldValue && value) {
|
|
49
|
+
// Attach controller asynchronously
|
|
50
|
+
attachController(this, value).catch(error => {
|
|
51
|
+
console.error(`Failed to attach controller "${value}":`, error);
|
|
52
|
+
});
|
|
53
|
+
} else if (!value && oldValue) {
|
|
54
|
+
// Detach controller asynchronously
|
|
55
|
+
detachController(this).catch(error => {
|
|
56
|
+
console.error(`Failed to detach controller:`, error);
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
enumerable: true,
|
|
61
|
+
configurable: true
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
constructor.prototype.connectedCallback = async function() {
|
|
65
|
+
// If ready promise was already created (controller attached before connected), use existing resolve
|
|
66
|
+
// Otherwise create the ready promise now
|
|
67
|
+
if (!this[READY_PROMISE]) {
|
|
68
|
+
this[READY_PROMISE] = new Promise<void>((resolve) => {
|
|
69
|
+
this[READY_RESOLVE] = resolve;
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
// Clean up any existing event handlers first (for reconnection)
|
|
75
|
+
cleanupEventHandlers(this);
|
|
76
|
+
|
|
77
|
+
// Create shadow root if it doesn't exist
|
|
78
|
+
if (!this.shadowRoot) {
|
|
79
|
+
this.attachShadow({ mode: 'open' });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Build the shadow DOM content
|
|
83
|
+
let shadowContent = '';
|
|
84
|
+
|
|
85
|
+
// Add HTML first (maintaining original order)
|
|
86
|
+
if (this.html) {
|
|
87
|
+
try {
|
|
88
|
+
const htmlResult = this.html();
|
|
89
|
+
// Handle both async and sync html
|
|
90
|
+
const htmlContent = htmlResult instanceof Promise ? await htmlResult : htmlResult;
|
|
91
|
+
if (htmlContent !== undefined) {
|
|
92
|
+
shadowContent += htmlContent;
|
|
93
|
+
}
|
|
94
|
+
} catch (error) {
|
|
95
|
+
console.error(`Error in html() method for ${this.tagName}:`, error);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Add CSS after HTML (maintaining original order)
|
|
100
|
+
if (this.css) {
|
|
101
|
+
try {
|
|
102
|
+
const cssResult = this.css();
|
|
103
|
+
// Handle both async and sync css
|
|
104
|
+
const cssResolved = cssResult instanceof Promise ? await cssResult : cssResult;
|
|
105
|
+
if (cssResolved) {
|
|
106
|
+
// Handle both string and array of strings
|
|
107
|
+
const cssContent = Array.isArray(cssResolved) ? cssResolved.join('\n') : cssResolved;
|
|
108
|
+
// No need for scoping with Shadow DOM, but add data attribute for compatibility
|
|
109
|
+
shadowContent += `<style data-component-css>${cssContent}</style>`;
|
|
110
|
+
}
|
|
111
|
+
} catch (error) {
|
|
112
|
+
console.error(`Error in css() method for ${this.tagName}:`, error);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Set shadow DOM content
|
|
117
|
+
if (shadowContent) {
|
|
118
|
+
this.shadowRoot.innerHTML = shadowContent;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
originalConnectedCallback?.call(this);
|
|
122
|
+
|
|
123
|
+
const controllerName = this.getAttribute('controller');
|
|
124
|
+
if (controllerName) {
|
|
125
|
+
this.controller = controllerName;
|
|
126
|
+
}
|
|
127
|
+
// Setup @on event handlers - use element for host events, shadow root for delegated events
|
|
128
|
+
setupEventHandlers(this, this);
|
|
129
|
+
} finally {
|
|
130
|
+
// Always mark element as ready, even if there were errors
|
|
131
|
+
if (this[READY_RESOLVE]) {
|
|
132
|
+
this[READY_RESOLVE]();
|
|
133
|
+
this[READY_RESOLVE] = null; // Clear the resolver
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
constructor.prototype.disconnectedCallback = function() {
|
|
139
|
+
originalDisconnectedCallback?.call(this);
|
|
140
|
+
if (this[CONTROLLER]) {
|
|
141
|
+
detachController(this).catch(error => {
|
|
142
|
+
console.error(`Failed to detach controller:`, error);
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
// Cleanup @on event handlers
|
|
146
|
+
cleanupEventHandlers(this);
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
constructor.prototype.attributeChangedCallback = function(name: string, oldValue: string, newValue: string) {
|
|
150
|
+
originalAttributeChangedCallback?.call(this, name, oldValue, newValue);
|
|
151
|
+
if (name === 'controller') {
|
|
152
|
+
this.controller = newValue;
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
customElements.define(tagName, constructor);
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Alias for backwards compatibility
|
|
161
|
+
export const customElement = element;
|
|
162
|
+
|
|
163
|
+
export function property(options?: PropertyOptions) {
|
|
164
|
+
return function (target: any, propertyKey: string) {
|
|
165
|
+
const constructor = target.constructor;
|
|
166
|
+
|
|
167
|
+
if (!constructor[PROPERTIES]) {
|
|
168
|
+
constructor[PROPERTIES] = new Map();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
constructor[PROPERTIES].set(propertyKey, options || {});
|
|
172
|
+
|
|
173
|
+
const descriptor: PropertyDescriptor = {
|
|
174
|
+
get(this: any) {
|
|
175
|
+
if (!this[PROPERTY_VALUES]) {
|
|
176
|
+
this[PROPERTY_VALUES] = {};
|
|
177
|
+
}
|
|
178
|
+
return this[PROPERTY_VALUES][propertyKey];
|
|
179
|
+
},
|
|
180
|
+
set(this: any, value: any) {
|
|
181
|
+
if (!this[PROPERTY_VALUES]) {
|
|
182
|
+
this[PROPERTY_VALUES] = {};
|
|
183
|
+
}
|
|
184
|
+
const oldValue = this[PROPERTY_VALUES][propertyKey];
|
|
185
|
+
|
|
186
|
+
// Don't update if value hasn't changed
|
|
187
|
+
if (oldValue === value) return;
|
|
188
|
+
|
|
189
|
+
this[PROPERTY_VALUES][propertyKey] = value;
|
|
190
|
+
|
|
191
|
+
if (options?.reflect && this.setAttribute) {
|
|
192
|
+
if (value === null || value === undefined || value === false) {
|
|
193
|
+
this.removeAttribute(options.attribute || propertyKey);
|
|
194
|
+
} else {
|
|
195
|
+
this.setAttribute(options.attribute || propertyKey, String(value));
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Call requestUpdate if available and value changed
|
|
200
|
+
if (this.requestUpdate) {
|
|
201
|
+
this.requestUpdate(propertyKey, oldValue);
|
|
202
|
+
}
|
|
203
|
+
},
|
|
204
|
+
enumerable: true,
|
|
205
|
+
configurable: true,
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
Object.defineProperty(target, propertyKey, descriptor);
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function query(selector: string) {
|
|
213
|
+
return function (target: any, propertyKey: string) {
|
|
214
|
+
Object.defineProperty(target, propertyKey, {
|
|
215
|
+
get() {
|
|
216
|
+
// For elements with shadow DOM, query within shadow root
|
|
217
|
+
// For controllers, check the element's shadow root first
|
|
218
|
+
const root = this.element || this;
|
|
219
|
+
if (root.shadowRoot) {
|
|
220
|
+
return root.shadowRoot.querySelector(selector);
|
|
221
|
+
}
|
|
222
|
+
return root.querySelector(selector);
|
|
223
|
+
},
|
|
224
|
+
enumerable: true,
|
|
225
|
+
configurable: true,
|
|
226
|
+
});
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function queryAll(selector: string) {
|
|
231
|
+
return function (target: any, propertyKey: string) {
|
|
232
|
+
Object.defineProperty(target, propertyKey, {
|
|
233
|
+
get() {
|
|
234
|
+
// For elements with shadow DOM, query within shadow root
|
|
235
|
+
// For controllers, check the element's shadow root first
|
|
236
|
+
const root = this.element || this;
|
|
237
|
+
if (root.shadowRoot) {
|
|
238
|
+
return root.shadowRoot.querySelectorAll(selector);
|
|
239
|
+
}
|
|
240
|
+
return root.querySelectorAll(selector);
|
|
241
|
+
},
|
|
242
|
+
enumerable: true,
|
|
243
|
+
configurable: true,
|
|
244
|
+
});
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export interface PropertyOptions {
|
|
249
|
+
type?: StringConstructor | NumberConstructor | BooleanConstructor | ArrayConstructor | ObjectConstructor;
|
|
250
|
+
reflect?: boolean;
|
|
251
|
+
attribute?: string | boolean;
|
|
252
|
+
converter?: PropertyConverter;
|
|
253
|
+
hasChanged?: (value: any, oldValue: any) => boolean;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export interface PropertyConverter {
|
|
257
|
+
fromAttribute?(value: string | null, type?: any): any;
|
|
258
|
+
toAttribute?(value: any, type?: any): string | null;
|
|
259
|
+
}
|
package/src/events.ts
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { ON_HANDLERS, CLEANUP } from './symbols';
|
|
2
|
+
|
|
3
|
+
export function on(eventName: string, selector?: string) {
|
|
4
|
+
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
|
|
5
|
+
// Store event handler metadata
|
|
6
|
+
if (!target[ON_HANDLERS]) {
|
|
7
|
+
target[ON_HANDLERS] = [];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
target[ON_HANDLERS].push({
|
|
11
|
+
eventName,
|
|
12
|
+
selector,
|
|
13
|
+
methodName: propertyKey,
|
|
14
|
+
method: descriptor.value
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
return descriptor;
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Helper to setup event handlers for elements
|
|
22
|
+
export function setupEventHandlers(instance: any, element: HTMLElement) {
|
|
23
|
+
const handlers = instance.constructor.prototype[ON_HANDLERS];
|
|
24
|
+
if (!handlers) return;
|
|
25
|
+
|
|
26
|
+
// Initialize cleanup object if needed
|
|
27
|
+
if (!instance[CLEANUP]) {
|
|
28
|
+
instance[CLEANUP] = { events: [], channels: [] };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
for (const handler of handlers) {
|
|
32
|
+
const originalMethod = handler.method.bind(instance);
|
|
33
|
+
|
|
34
|
+
// Wrap boundMethod in try-catch for error isolation
|
|
35
|
+
const boundMethod = (event: Event) => {
|
|
36
|
+
try {
|
|
37
|
+
return originalMethod(event);
|
|
38
|
+
} catch (error) {
|
|
39
|
+
console.error(`Error in event handler ${handler.methodName}:`, error);
|
|
40
|
+
// Don't rethrow - allow other handlers to continue
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
if (handler.selector) {
|
|
45
|
+
// Delegated event handling - use shadow root if available
|
|
46
|
+
const eventRoot = element.shadowRoot || element;
|
|
47
|
+
const delegatedHandler = (event: Event) => {
|
|
48
|
+
const target = event.target as HTMLElement;
|
|
49
|
+
if (target.matches && target.matches(handler.selector)) {
|
|
50
|
+
boundMethod(event);
|
|
51
|
+
} else if (target.closest) {
|
|
52
|
+
const closest = target.closest(handler.selector);
|
|
53
|
+
if (closest) {
|
|
54
|
+
boundMethod(event);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
eventRoot.addEventListener(handler.eventName, delegatedHandler);
|
|
60
|
+
instance[CLEANUP].events.push(() => {
|
|
61
|
+
eventRoot.removeEventListener(handler.eventName, delegatedHandler);
|
|
62
|
+
});
|
|
63
|
+
} else {
|
|
64
|
+
// Direct event handling - always on the element itself
|
|
65
|
+
element.addEventListener(handler.eventName, boundMethod);
|
|
66
|
+
instance[CLEANUP].events.push(() => {
|
|
67
|
+
element.removeEventListener(handler.eventName, boundMethod);
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Helper to cleanup event handlers
|
|
74
|
+
export function cleanupEventHandlers(instance: any) {
|
|
75
|
+
if (instance[CLEANUP]?.events) {
|
|
76
|
+
for (const cleanup of instance[CLEANUP].events) {
|
|
77
|
+
cleanup();
|
|
78
|
+
}
|
|
79
|
+
instance[CLEANUP].events = [];
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface DispatchOptions extends EventInit {
|
|
84
|
+
/**
|
|
85
|
+
* Whether to dispatch even if the method returns undefined (default: true)
|
|
86
|
+
*/
|
|
87
|
+
dispatchOnUndefined?: boolean;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Decorator that automatically dispatches a custom event after a method is called.
|
|
92
|
+
* The return value of the method becomes the event detail.
|
|
93
|
+
*
|
|
94
|
+
* @param eventName The name of the event to dispatch
|
|
95
|
+
* @param options Optional configuration extending EventInit
|
|
96
|
+
*/
|
|
97
|
+
export function dispatch(eventName: string, options?: DispatchOptions) {
|
|
98
|
+
return function (_target: any, _propertyKey: string, descriptor: PropertyDescriptor) {
|
|
99
|
+
const originalMethod = descriptor.value;
|
|
100
|
+
|
|
101
|
+
descriptor.value = function (this: HTMLElement, ...args: any[]) {
|
|
102
|
+
// Call the original method
|
|
103
|
+
const result = originalMethod.apply(this, args);
|
|
104
|
+
|
|
105
|
+
// Handle async methods
|
|
106
|
+
if (result instanceof Promise) {
|
|
107
|
+
return result.then((resolvedResult: any) => {
|
|
108
|
+
// Skip dispatch if result is undefined and dispatchOnUndefined is false
|
|
109
|
+
if (resolvedResult === undefined && options?.dispatchOnUndefined === false) {
|
|
110
|
+
return resolvedResult;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Create event with spread operator for options
|
|
114
|
+
const event = new CustomEvent(eventName, {
|
|
115
|
+
bubbles: true, // Default to true for component events
|
|
116
|
+
composed: true, // Allow crossing shadow DOM boundaries
|
|
117
|
+
...options, // Spread all EventInit options
|
|
118
|
+
detail: resolvedResult
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
this.dispatchEvent(event);
|
|
122
|
+
return resolvedResult;
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Skip dispatch if result is undefined and dispatchOnUndefined is false
|
|
127
|
+
if (result === undefined && options?.dispatchOnUndefined === false) {
|
|
128
|
+
return result;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Create event with spread operator for options
|
|
132
|
+
const event = new CustomEvent(eventName, {
|
|
133
|
+
bubbles: true, // Default to true for component events
|
|
134
|
+
composed: true, // Allow crossing shadow DOM boundaries
|
|
135
|
+
...options, // Spread all EventInit options
|
|
136
|
+
detail: result
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
this.dispatchEvent(event);
|
|
140
|
+
|
|
141
|
+
return result;
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
return descriptor;
|
|
145
|
+
};
|
|
146
|
+
}
|
package/src/global.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Global namespace for Snice framework
|
|
2
|
+
// Ensures all registries and symbols are shared across different JS files/modules
|
|
3
|
+
|
|
4
|
+
export interface SniceGlobal {
|
|
5
|
+
controllerRegistry: Map<string, any>;
|
|
6
|
+
controllerIdCounter: number;
|
|
7
|
+
symbols: Map<string, symbol>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// Initialize or get the global Snice namespace
|
|
11
|
+
function initGlobalNamespace(): SniceGlobal {
|
|
12
|
+
if (!(globalThis as any).snice) {
|
|
13
|
+
(globalThis as any).snice = {
|
|
14
|
+
controllerRegistry: new Map(),
|
|
15
|
+
controllerIdCounter: 0,
|
|
16
|
+
symbols: new Map()
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
return (globalThis as any).snice;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Export the global namespace
|
|
23
|
+
export const snice = initGlobalNamespace();
|
|
24
|
+
|
|
25
|
+
// Helper function to get or create a global symbol
|
|
26
|
+
export function getSymbol(name: string): symbol {
|
|
27
|
+
if (!snice.symbols.has(name)) {
|
|
28
|
+
snice.symbols.set(name, Symbol(name));
|
|
29
|
+
}
|
|
30
|
+
return snice.symbols.get(name)!;
|
|
31
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export { element, customElement, property, query, queryAll } from './element';
|
|
2
|
+
export { Router } from './router';
|
|
3
|
+
export { controller, attachController, detachController, getController, useNativeElementControllers, cleanupNativeElementControllers } from './controller';
|
|
4
|
+
export { on, dispatch } from './events';
|
|
5
|
+
export { channel } from './channel';
|
|
6
|
+
export type { PropertyOptions, PropertyConverter } from './element';
|
|
7
|
+
export type { RouterOptions, PageOptions, PageTransition } from './router';
|
|
8
|
+
export type { IController, ControllerClass } from './controller';
|
|
9
|
+
export type { DispatchOptions } from './events';
|
|
10
|
+
export type { ChannelOptions } from './channel';
|