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