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/router.ts ADDED
@@ -0,0 +1,386 @@
1
+ import Route from 'route-parser';
2
+ import { setupEventHandlers, cleanupEventHandlers } from './events';
3
+
4
+ export interface RouterOptions {
5
+ /**
6
+ * The target element selector where the page element will be instantiated.
7
+ * The router will use this selector to find the target element, clear it, and append the page element to it.
8
+ */
9
+ target: string;
10
+
11
+ /**
12
+ * Whether to use hash routing or push state routing.
13
+ */
14
+ routing_type: 'hash' | 'pushstate';
15
+
16
+ /**
17
+ * Override for the window object to use for routing, defaults to global.
18
+ */
19
+ window?: Window;
20
+
21
+ /**
22
+ * Override for the document object to use for routing, defaults to global.
23
+ */
24
+ document?: Document;
25
+
26
+ /**
27
+ * Global transition configuration for all pages
28
+ */
29
+ transition?: PageTransition;
30
+ }
31
+
32
+ export interface PageTransition {
33
+ /**
34
+ * Name of the transition (for CSS class naming)
35
+ */
36
+ name?: string;
37
+
38
+ /**
39
+ * Duration of the out transition in ms
40
+ */
41
+ outDuration?: number;
42
+
43
+ /**
44
+ * Duration of the in transition in ms
45
+ */
46
+ inDuration?: number;
47
+
48
+ /**
49
+ * CSS classes or styles for the out transition
50
+ */
51
+ out?: string;
52
+
53
+ /**
54
+ * CSS classes or styles for the in transition
55
+ */
56
+ in?: string;
57
+
58
+ /**
59
+ * Mode: 'sequential' (out then in) or 'simultaneous' (both at once)
60
+ */
61
+ mode?: 'sequential' | 'simultaneous';
62
+ }
63
+
64
+ export interface PageOptions {
65
+ /**
66
+ * The tag name of the custom element.
67
+ * @example { tag: 'login-page' }
68
+ * // for <login-page></login-page>
69
+ */
70
+ tag: string;
71
+
72
+ /**
73
+ * The routes that will trigger the page element.
74
+ * @example { routes: ['/login', '/login/:id'] }
75
+ */
76
+ routes: string[];
77
+
78
+ /**
79
+ * Optional per-page transition override
80
+ */
81
+ transition?: PageTransition;
82
+ }
83
+
84
+ /**
85
+ * Creates a new router instance.
86
+ * @param {RouterOptions} options - The router configuration options.
87
+ * @returns An object containing the router's API methods.
88
+ */
89
+ export function Router(options: RouterOptions) {
90
+ const routes: { route: Route, tag: string, transition?: PageTransition }[] = [];
91
+ let is_sorted = false;
92
+
93
+ let _404: string; // the 404 page
94
+ let home: string; // the home page
95
+ let currentPageElement: HTMLElement | null = null; // Track current page for transitions
96
+
97
+ /**
98
+ * Decorator function for defining a page with associated routes.
99
+ * @param {PageOptions} pageOptions - The page configuration options.
100
+ * @returns A decorator function to apply to a custom element class.
101
+ */
102
+ function page(pageOptions: PageOptions) {
103
+ return function <T extends { new(...args: any[]): HTMLElement }>(constructor: T) {
104
+ // Store transition config on constructor for later use
105
+ (constructor as any).__transition = pageOptions.transition;
106
+ // Add event handler support
107
+ const originalConnectedCallback = constructor.prototype.connectedCallback;
108
+ const originalDisconnectedCallback = constructor.prototype.disconnectedCallback;
109
+
110
+ constructor.prototype.connectedCallback = function() {
111
+ // Call original connectedCallback first to allow property initialization
112
+ originalConnectedCallback?.call(this);
113
+
114
+ // Create shadow root if it doesn't exist
115
+ if (!this.shadowRoot) {
116
+ this.attachShadow({ mode: 'open' });
117
+ }
118
+
119
+ // Build the shadow DOM content
120
+ let shadowContent = '';
121
+
122
+ // Add HTML first (maintaining original order)
123
+ if (this.html) {
124
+ const htmlContent = this.html();
125
+ if (htmlContent !== undefined) {
126
+ shadowContent += htmlContent;
127
+ }
128
+ }
129
+
130
+ // Add CSS after HTML (maintaining original order)
131
+ if (this.css) {
132
+ const cssResult = this.css();
133
+ if (cssResult) {
134
+ // Handle both string and array of strings
135
+ const cssContent = Array.isArray(cssResult) ? cssResult.join('\n') : cssResult;
136
+ // No need for scoping with Shadow DOM, but add data attribute for compatibility
137
+ shadowContent += `<style data-component-css>${cssContent}</style>`;
138
+ }
139
+ }
140
+
141
+ // Set shadow DOM content
142
+ if (shadowContent) {
143
+ this.shadowRoot.innerHTML = shadowContent;
144
+ }
145
+ // Setup @on event handlers - use element for host events, shadow root for delegated events
146
+ setupEventHandlers(this, this);
147
+ };
148
+
149
+ constructor.prototype.disconnectedCallback = function() {
150
+ originalDisconnectedCallback?.call(this);
151
+ // Cleanup @on event handlers
152
+ cleanupEventHandlers(this);
153
+ };
154
+
155
+ // Define the custom element
156
+ customElements.define(pageOptions.tag, constructor);
157
+
158
+ // Register the routes
159
+ pageOptions.routes.forEach(route => register(route, pageOptions.tag));
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Registers a new route with the router.
165
+ * @param {string} route - The route path.
166
+ * @param {string} tag - The custom element tag associated with the route.
167
+ * @example
168
+ * register('/custom-route', 'custom-element');
169
+ */
170
+ function register(route: string, tag: string, transition?: PageTransition): void {
171
+ routes.push({ route: new Route(route), tag, transition });
172
+ is_sorted = false;
173
+
174
+ if (route === '/404') {
175
+ _404 = tag;
176
+ }
177
+
178
+ if (route === '/') {
179
+ home = tag;
180
+ }
181
+ }
182
+
183
+ /**
184
+ * Initializes the router and sets up navigation event listeners.
185
+ * @example
186
+ * initialize();
187
+ */
188
+ function initialize(): void {
189
+ // Check if target exists before initializing
190
+ if (!document.querySelector(options.target)) {
191
+ throw new Error(`Target element not found: ${options.target}`);
192
+ }
193
+
194
+ if (!is_sorted) {
195
+ routes.sort((a: any, b: any) => b.route.spec.length - a.route.spec.length);
196
+ is_sorted = true;
197
+ }
198
+
199
+ // Listen for navigation events
200
+ switch (options.routing_type) {
201
+ case 'hash':
202
+ window.addEventListener('hashchange', () => {
203
+ // Only navigate if target still exists
204
+ if (document.querySelector(options.target)) {
205
+ const path = get_path();
206
+ navigate(path);
207
+ }
208
+ });
209
+ break;
210
+ case 'pushstate':
211
+ window.addEventListener('popstate', () => {
212
+ // Only navigate if target still exists
213
+ if (document.querySelector(options.target)) {
214
+ const path = get_path();
215
+ navigate(path);
216
+ }
217
+ });
218
+ break;
219
+ }
220
+
221
+ const path = get_path();
222
+ navigate(path);
223
+ }
224
+
225
+ function get_path(): string {
226
+ switch (options.routing_type) {
227
+ case 'hash':
228
+ return window.location.hash.slice(1);
229
+ case 'pushstate':
230
+ return window.location.pathname;
231
+ }
232
+ }
233
+
234
+ /**
235
+ * Navigates to the specified path.
236
+ * @param {string} path - The path to navigate to.
237
+ * @example
238
+ * navigate('/login');
239
+ */
240
+ async function navigate(path: string): Promise<void> {
241
+ const target = document.querySelector(options.target);
242
+ if (!target) {
243
+ throw new Error(`Target element not found: ${options.target}`);
244
+ }
245
+
246
+ let newPageElement: HTMLElement | null = null;
247
+ let transition: PageTransition | undefined;
248
+
249
+ // Home
250
+ if ((path.trim() === '' || path === '/') && home) {
251
+ newPageElement = document.createElement(home);
252
+ const constructor = customElements.get(home);
253
+ transition = (constructor as any)?.__transition;
254
+ } else {
255
+
256
+ // Get the current route
257
+ for (const route of routes) {
258
+ const params = route.route.match(path);
259
+ const is_match = params !== false;
260
+
261
+ if (is_match) {
262
+ newPageElement = document.createElement(route.tag);
263
+ Object.keys(params).forEach(key => newPageElement!.setAttribute(key, params[key]));
264
+ transition = route.transition;
265
+ break;
266
+ }
267
+ }
268
+ }
269
+
270
+ // 404
271
+ if (!newPageElement) {
272
+ if (_404) {
273
+ newPageElement = document.createElement(_404);
274
+ const constructor = customElements.get(_404);
275
+ transition = (constructor as any)?.__transition;
276
+ } else {
277
+ // Provide a default 404 page
278
+ const div = document.createElement('div');
279
+ div.className = 'default-404';
280
+ div.innerHTML = '<h1>404</h1><p>Page not found</p>';
281
+ newPageElement = div;
282
+ }
283
+ }
284
+
285
+ // Use page-specific or global transition
286
+ transition = transition || options.transition;
287
+
288
+ // Perform transition
289
+ if (transition && currentPageElement && currentPageElement.parentElement) {
290
+ await performTransition(target, currentPageElement, newPageElement!, transition);
291
+ } else {
292
+ // No transition, just swap
293
+ target.innerHTML = '';
294
+ if (newPageElement) {
295
+ target.appendChild(newPageElement);
296
+ }
297
+ }
298
+
299
+ currentPageElement = newPageElement;
300
+ }
301
+
302
+ async function performTransition(
303
+ container: Element,
304
+ oldElement: HTMLElement,
305
+ newElement: HTMLElement,
306
+ transition: PageTransition
307
+ ): Promise<void> {
308
+ const outDuration = transition.outDuration || 300;
309
+ const inDuration = transition.inDuration || 300;
310
+ const mode = transition.mode || 'sequential';
311
+
312
+ // Parse CSS properties from transition config
313
+ const parseStyles = (styleString: string): Record<string, string> => {
314
+ const styles: Record<string, string> = {};
315
+ styleString.split(';').forEach(rule => {
316
+ const [prop, value] = rule.split(':').map(s => s.trim());
317
+ if (prop && value) {
318
+ styles[prop] = value;
319
+ }
320
+ });
321
+ return styles;
322
+ };
323
+
324
+ // Default transitions
325
+ const outStyles = transition.out ? parseStyles(transition.out) : { opacity: '0' };
326
+ const inStartStyles = { opacity: '0' }; // Always start invisible
327
+ const inEndStyles = transition.in ? parseStyles(transition.in) : { opacity: '1' };
328
+
329
+ // Set container to relative positioning to allow absolute positioning of pages
330
+ const containerStyle = (container as HTMLElement).style;
331
+ const originalPosition = containerStyle.position;
332
+ containerStyle.position = 'relative';
333
+
334
+ // Style old element for transition
335
+ oldElement.style.position = 'absolute';
336
+ oldElement.style.top = '0';
337
+ oldElement.style.left = '0';
338
+ oldElement.style.width = '100%';
339
+ oldElement.style.transition = `all ${outDuration}ms ease-in-out`;
340
+
341
+ // Style new element with initial state
342
+ newElement.style.position = 'absolute';
343
+ newElement.style.top = '0';
344
+ newElement.style.left = '0';
345
+ newElement.style.width = '100%';
346
+ Object.assign(newElement.style, inStartStyles);
347
+ newElement.style.transition = `all ${inDuration}ms ease-in-out`;
348
+
349
+ // Add new element to container
350
+ container.appendChild(newElement);
351
+
352
+ // Force browser to calculate styles
353
+ void newElement.offsetHeight;
354
+
355
+ if (mode === 'simultaneous') {
356
+ // Start both transitions at once
357
+ Object.assign(oldElement.style, outStyles);
358
+ Object.assign(newElement.style, inEndStyles);
359
+
360
+ // Wait for both transitions to complete
361
+ await new Promise(resolve => setTimeout(resolve, Math.max(outDuration, inDuration)));
362
+ } else {
363
+ // Sequential: transition out old, then transition in new
364
+ Object.assign(oldElement.style, outStyles);
365
+ await new Promise(resolve => setTimeout(resolve, outDuration));
366
+
367
+ Object.assign(newElement.style, inEndStyles);
368
+ await new Promise(resolve => setTimeout(resolve, inDuration));
369
+ }
370
+
371
+ // Cleanup
372
+ oldElement.remove();
373
+ newElement.style.position = '';
374
+ newElement.style.top = '';
375
+ newElement.style.left = '';
376
+ newElement.style.width = '';
377
+ newElement.style.transition = '';
378
+ // Reset any transition styles
379
+ Object.keys({...inStartStyles, ...inEndStyles}).forEach(prop => {
380
+ newElement.style[prop as any] = '';
381
+ });
382
+ containerStyle.position = originalPosition;
383
+ }
384
+
385
+ return { page, initialize, navigate, register };
386
+ }
package/src/symbols.ts ADDED
@@ -0,0 +1,30 @@
1
+ // Central file for all symbols used in the framework
2
+ // All symbols are stored globally to ensure consistency across modules
3
+
4
+ import { getSymbol } from './global';
5
+
6
+ export const IS_CONTROLLER_CLASS = getSymbol('is-controller-class');
7
+ export const IS_ELEMENT_CLASS = getSymbol('is-element-class');
8
+ export const CHANNEL_HANDLERS = getSymbol('channel-handlers');
9
+
10
+ // Internal element state symbols
11
+ export const READY_PROMISE = getSymbol('ready-promise');
12
+ export const READY_RESOLVE = getSymbol('ready-resolve');
13
+ export const CONTROLLER = getSymbol('controller');
14
+
15
+ // Event handler symbols
16
+ export const ON_HANDLERS = getSymbol('on-handlers');
17
+
18
+ // Controller symbols
19
+ export const CONTROLLER_KEY = getSymbol('controller-key');
20
+ export const CONTROLLER_NAME_KEY = getSymbol('controller-name');
21
+ export const CONTROLLER_ID = getSymbol('controller-id');
22
+ export const CONTROLLER_OPERATIONS = getSymbol('controller-operations');
23
+ export const NATIVE_CONTROLLER = getSymbol('native-controller');
24
+
25
+ // Cleanup symbol - holds an object with all cleanup arrays
26
+ export const CLEANUP = getSymbol('cleanup');
27
+
28
+ // Property symbols
29
+ export const PROPERTIES = getSymbol('properties');
30
+ export const PROPERTY_VALUES = getSymbol('property-values');