application.ts 1.0.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/app/App.ts ADDED
@@ -0,0 +1,391 @@
1
+ import StackView from 'stackview.ts';
2
+ import { Router } from '../navigation/router';
3
+ import { NavigationEvents, type NavigationEventDetail } from '../navigation/types';
4
+ import type { AppView } from './AppView';
5
+
6
+ // Force StackView to be included in the bundle by using it
7
+ // This ensures the custom element is registered when the module loads
8
+
9
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
10
+ const _stackView = StackView;
11
+
12
+ /**
13
+ * View constructor type
14
+ */
15
+ type ViewConstructor = new () => AppView<any>;
16
+
17
+ /**
18
+ * Main App class that manages routing and view rendering
19
+ * Integrates Router with StackView.Ts for navigation
20
+ *
21
+ * @example
22
+ * ```typescript
23
+ * import { App } from 'application.ts';
24
+ * import { HomeView, AboutView, NotFoundView } from './views';
25
+ *
26
+ * const app = new App('#root');
27
+ *
28
+ * app.router
29
+ * .map('/', HomeView)
30
+ * .map('/about', AboutView)
31
+ * .notFound(NotFoundView);
32
+ *
33
+ * app.start();
34
+ * ```
35
+ */
36
+ export class App {
37
+ public readonly router: Router;
38
+ private stackView: any = null;
39
+ private rootElement: HTMLElement | null = null;
40
+ private viewRegistry: Map<string, ViewConstructor> = new Map();
41
+ private layoutRegistry: Map<string, ViewConstructor> = new Map();
42
+ private currentViewInstance: AppView<any> | null = null;
43
+ private currentLayoutInstance: AppView<any> | null = null;
44
+ private currentRoutePath: string | null = null;
45
+ private defaultLayout: string | null = null;
46
+
47
+ /**
48
+ * Get the App instance from any element by traversing up the DOM tree
49
+ * @param element - Any HTMLElement in the app
50
+ * @returns App instance or null if not found
51
+ */
52
+ static fromElement(element: HTMLElement | null): App | null {
53
+ let current = element;
54
+
55
+ while (current) {
56
+ if ((current as any).__app__) {
57
+ return (current as any).__app__;
58
+ }
59
+ current = current.parentElement;
60
+ }
61
+
62
+ return null;
63
+ }
64
+
65
+ constructor(rootSelector?: string) {
66
+ this.router = new Router();
67
+
68
+ // Listen to navigation events
69
+ this.router.addEventListener(NavigationEvents.NAVIGATE, ((e: CustomEvent<NavigationEventDetail>) => {
70
+ this.handleNavigation(e);
71
+ }) as EventListener);
72
+
73
+ this.router.addEventListener(NavigationEvents.NOT_FOUND, ((e: CustomEvent<NavigationEventDetail>) => {
74
+ this.handleNotFound(e);
75
+ }) as EventListener);
76
+
77
+ // If root selector provided, initialize immediately
78
+ if (rootSelector) {
79
+ this.initializeRoot(rootSelector);
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Register a view class with a handler name
85
+ * @param handler - The view handler/identifier used in router.map()
86
+ * @param viewClass - The AppView class constructor
87
+ */
88
+ registerView(handler: string, viewClass: ViewConstructor): this {
89
+ // Auto-register the custom element
90
+ (viewClass as any).register();
91
+ this.viewRegistry.set(handler, viewClass);
92
+ return this;
93
+ }
94
+
95
+ /**
96
+ * Register multiple views at once
97
+ * @param views - Object mapping handler names to view classes
98
+ */
99
+ registerViews(views: Record<string, ViewConstructor>): this {
100
+ for (const [handler, viewClass] of Object.entries(views)) {
101
+ this.registerView(handler, viewClass);
102
+ }
103
+ return this;
104
+ }
105
+
106
+ /**
107
+ * Register a layout class with a handler name
108
+ * @param handler - The layout handler/identifier
109
+ * @param layoutClass - The AppView layout class constructor
110
+ */
111
+ registerLayout(handler: string, layoutClass: ViewConstructor): this {
112
+ // Auto-register the custom element
113
+ (layoutClass as any).register();
114
+ this.layoutRegistry.set(handler, layoutClass);
115
+ return this;
116
+ }
117
+
118
+ /**
119
+ * Register multiple layouts at once
120
+ * @param layouts - Object mapping handler names to layout classes
121
+ */
122
+ registerLayouts(layouts: Record<string, ViewConstructor>): this {
123
+ for (const [handler, layoutClass] of Object.entries(layouts)) {
124
+ this.registerLayout(handler, layoutClass);
125
+ }
126
+ return this;
127
+ }
128
+
129
+ /**
130
+ * Set the default layout for all routes
131
+ * @param handler - The layout handler name, or null for no layout
132
+ */
133
+ setDefaultLayout(handler: string | null): this {
134
+ this.defaultLayout = handler;
135
+ return this;
136
+ }
137
+
138
+ /**
139
+ * Initialize the root element and StackView
140
+ * @param selector - CSS selector for the root element
141
+ */
142
+ private initializeRoot(selector: string): void {
143
+ const element = document.querySelector(selector);
144
+ if (!element) {
145
+ throw new Error(`Root element not found: ${selector}`);
146
+ }
147
+
148
+ this.rootElement = element as HTMLElement;
149
+
150
+ // Attach app instance to root element
151
+ (this.rootElement as any).__app__ = this;
152
+
153
+ // Create StackView if it doesn't exist
154
+ if (!this.stackView) {
155
+ // Create or use existing stack-view element
156
+ let stackViewElement = this.rootElement.querySelector('stack-view') as StackView;
157
+
158
+ if (!stackViewElement) {
159
+ stackViewElement = new StackView();
160
+ stackViewElement.effect = 'fade';
161
+ stackViewElement.backButton = false;
162
+ this.rootElement.appendChild(stackViewElement);
163
+ }
164
+
165
+ this.stackView = stackViewElement;
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Start the application
171
+ * @param rootSelector - Optional root selector if not provided in constructor
172
+ */
173
+ start(rootSelector?: string): void {
174
+ if (rootSelector) {
175
+ this.initializeRoot(rootSelector);
176
+ }
177
+
178
+ if (!this.rootElement || !this.stackView) {
179
+ throw new Error('Root element not initialized. Provide selector in constructor or start()');
180
+ }
181
+
182
+ // Start the router
183
+ this.router.start();
184
+ }
185
+
186
+ /**
187
+ * Resolve view class from handler (supports both class constructor and string lookup)
188
+ * @param handler - View class constructor or string identifier
189
+ * @returns View class constructor or null
190
+ */
191
+ private resolveViewClass(handler: any): ViewConstructor | null {
192
+ // If handler is already a class constructor, use it directly
193
+ if (typeof handler === 'function') {
194
+ // Auto-register the custom element
195
+ (handler as any).register?.();
196
+ return handler as ViewConstructor;
197
+ }
198
+
199
+ // If handler is a string, look it up in the registry (backward compatibility)
200
+ if (typeof handler === 'string') {
201
+ return this.viewRegistry.get(handler) || null;
202
+ }
203
+
204
+ return null;
205
+ }
206
+
207
+ /**
208
+ * Handle navigation event from router
209
+ */
210
+ private async handleNavigation(event: CustomEvent<NavigationEventDetail>): Promise<void> {
211
+ const { handler, params, meta, path } = event.detail;
212
+
213
+ // Store the current route path pattern
214
+ this.currentRoutePath = this.getRoutePattern(path);
215
+
216
+ // Resolve view class from handler (can be class constructor or string)
217
+ const ViewClass = this.resolveViewClass(handler);
218
+
219
+ if (!ViewClass) {
220
+ console.error(`View not found or registered: ${handler}`);
221
+ return;
222
+ }
223
+
224
+ // Check if we're navigating to the same view instance with different params
225
+ if (this.currentViewInstance && this.currentViewInstance.constructor === ViewClass) {
226
+ // Same view, just update parameters
227
+ await this.currentViewInstance.updateParams(params);
228
+ return;
229
+ }
230
+
231
+ // Determine which layout to use
232
+ const layoutHandler = meta?.layout !== undefined ? meta.layout : this.defaultLayout;
233
+
234
+ // Create view instance
235
+ const viewInstance = new ViewClass();
236
+
237
+ // Store current view instance
238
+ this.currentViewInstance = viewInstance;
239
+
240
+ // If layout is specified, wrap view in layout
241
+ if (layoutHandler) {
242
+ await this.renderWithLayout(viewInstance, layoutHandler, meta);
243
+ } else {
244
+ await this.renderWithoutLayout(viewInstance, meta);
245
+ }
246
+ }
247
+
248
+ /**
249
+ * Render view wrapped in a layout
250
+ */
251
+ private async renderWithLayout(viewInstance: AppView<any>, layoutHandler: string, meta?: any): Promise<void> {
252
+ const LayoutClass = this.layoutRegistry.get(layoutHandler);
253
+
254
+ if (!LayoutClass) {
255
+ console.error(`Layout not registered: ${layoutHandler}`);
256
+ // Fallback to no layout
257
+ await this.renderWithoutLayout(viewInstance, meta);
258
+ return;
259
+ }
260
+
261
+ // Create or reuse layout instance
262
+ if (!this.currentLayoutInstance || this.currentLayoutInstance.constructor !== LayoutClass) {
263
+ // Create new layout instance
264
+ const layoutInstance = new LayoutClass();
265
+ this.currentLayoutInstance = layoutInstance;
266
+
267
+ // Show layout in StackView
268
+ if (this.stackView) {
269
+ await this.stackView.begin(layoutInstance, meta?.effect);
270
+ }
271
+ }
272
+
273
+ // Find content outlet in layout (should be a <stack-view> element)
274
+ const outlet = this.currentLayoutInstance.querySelector('[data-outlet="content"]') as StackView;
275
+
276
+ if (!outlet) {
277
+ console.error('Layout does not have a content outlet [data-outlet="content"]');
278
+ return;
279
+ }
280
+
281
+ // Check if outlet is a stack-view, if not create one
282
+ let stackViewElement: StackView;
283
+
284
+ if (outlet.tagName.toLowerCase() === 'stack-view') {
285
+ stackViewElement = outlet;
286
+ } else {
287
+ // Outlet is not a stack-view, create one inside it
288
+ let existingStackView = outlet.querySelector('stack-view') as StackView;
289
+
290
+ if (!existingStackView) {
291
+ existingStackView = new StackView();
292
+ existingStackView.effect = 'fade';
293
+ existingStackView.backButton = false;
294
+ outlet.appendChild(existingStackView);
295
+ }
296
+
297
+ stackViewElement = existingStackView;
298
+ }
299
+
300
+ // Use stack-view's begin method for smooth transitions
301
+ await stackViewElement.begin(viewInstance, meta?.effect);
302
+
303
+ // Trigger view lifecycle
304
+ if (viewInstance.onMounted) {
305
+ await viewInstance.onMounted();
306
+ }
307
+ }
308
+
309
+ /**
310
+ * Render view without layout
311
+ */
312
+ private async renderWithoutLayout(viewInstance: AppView<any>, meta?: any): Promise<void> {
313
+ // Clear current layout if exists
314
+ this.currentLayoutInstance = null;
315
+
316
+ // Show view using StackView
317
+ if (this.stackView) {
318
+ await this.stackView.begin(viewInstance, meta?.effect);
319
+ }
320
+ }
321
+
322
+ /**
323
+ * Handle not found event from router
324
+ */
325
+ private async handleNotFound(event: CustomEvent<NavigationEventDetail>): Promise<void> {
326
+ const { handler } = event.detail;
327
+
328
+ // Resolve view class from handler (can be class constructor or string)
329
+ const ViewClass = this.resolveViewClass(handler);
330
+
331
+ if (!ViewClass) {
332
+ console.error(`404 View not found or registered: ${handler}`);
333
+ return;
334
+ }
335
+
336
+ // Create view instance
337
+ const viewInstance = new ViewClass();
338
+
339
+ // Store current view instance
340
+ this.currentViewInstance = viewInstance;
341
+
342
+ // 404 pages typically don't use layouts, render directly
343
+ await this.renderWithoutLayout(viewInstance);
344
+ }
345
+
346
+ /**
347
+ * Get the route pattern that matches the given path
348
+ */
349
+ public getRoutePattern(path: string): string | null {
350
+ // Iterate through all registered routes to find the matching pattern
351
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
352
+ for (const [pattern, _] of (this.router as any).routes) {
353
+ const route = (this.router as any).routes.get(pattern).route;
354
+ if (route.match(path) !== null) {
355
+ return pattern;
356
+ }
357
+ }
358
+ return null;
359
+ }
360
+
361
+ /**
362
+ * Get the current view instance
363
+ */
364
+ get currentView(): AppView<any> | null {
365
+ return this.currentViewInstance;
366
+ }
367
+
368
+ /**
369
+ * Get the current route pattern (e.g., '/user/:id')
370
+ */
371
+ get currentRoute(): string | null {
372
+ return this.currentRoutePath;
373
+ }
374
+
375
+ /**
376
+ * Navigate to a specific path
377
+ * @param path - The path to navigate to
378
+ */
379
+ navigate(path: string): void {
380
+ this.router.navigate(path);
381
+ }
382
+
383
+ /**
384
+ * Go back in navigation history
385
+ */
386
+ async goBack(): Promise<void> {
387
+ if (this.stackView && this.stackView.canGoBack()) {
388
+ await this.stackView.complete();
389
+ }
390
+ }
391
+ }