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/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';