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.
@@ -0,0 +1,333 @@
1
+ import { TemplateBinder } from 'template.ts';
2
+ import type { RouteParams } from '../navigation/types';
3
+ import { App } from './App';
4
+ import type {
5
+ AppViewLifecycle,
6
+ AppViewOptions,
7
+ AppViewState
8
+ } from './types';
9
+
10
+ /**
11
+ * Abstract base class for creating views with Template.Ts
12
+ * Extends HTMLElement as a Web Component for seamless integration with StackView.Ts
13
+ * Custom elements are automatically registered when views are registered with App
14
+ *
15
+ * @example
16
+ * ```typescript
17
+ * const template: `
18
+ * <div>
19
+ * <h1>Count: {{ count }}</h1>
20
+ * <button @on:click="increment">Increment</button>
21
+ * </div>`;
22
+ *
23
+ * class State {
24
+ * count: number = 0;
25
+ * increment: () => { this.count += 1; };
26
+ * }
27
+ *
28
+ * @Register
29
+ * class HomeView extends AppView<{ count: number }> {
30
+ * template(): string {
31
+ * return template;
32
+ * }
33
+ *
34
+ * state() {
35
+ * return new State();
36
+ * }
37
+ * }
38
+ *
39
+ * // Register with app - automatically registers as <home-view>
40
+ * app.registerView('HomeView', HomeView);
41
+ * ```
42
+ */
43
+ export abstract class AppView<TState extends AppViewState = AppViewState> extends HTMLElement implements AppViewLifecycle {
44
+ protected binder: TemplateBinder | null = null;
45
+ protected _state: TState | null = null;
46
+ protected _options: AppViewOptions;
47
+ protected _root: HTMLElement | ShadowRoot;
48
+ private _isInitialized: boolean = false;
49
+
50
+ constructor(options?: AppViewOptions) {
51
+ super();
52
+
53
+ this._options = {
54
+ transitionClass: 'transition-fade',
55
+ autoUpdate: true,
56
+ useShadowDOM: false,
57
+ ...options
58
+ };
59
+
60
+ // Create shadow root if enabled
61
+ if (this._options.useShadowDOM) {
62
+ const shadow = this.attachShadow({ mode: 'open' });
63
+ shadow.innerHTML = this.template();
64
+ if (!shadow.firstElementChild || !(shadow.firstElementChild instanceof HTMLElement)) {
65
+ throw new Error('AppView template must have a single root element');
66
+ }
67
+ this._root = shadow.firstElementChild as HTMLElement;
68
+ } else {
69
+ this.innerHTML = this.template();
70
+ if (!this.firstElementChild || !(this.firstElementChild instanceof HTMLElement)) {
71
+ throw new Error('AppView template must have a single root element');
72
+ }
73
+ this._root = this.firstElementChild as HTMLElement;
74
+ }
75
+
76
+ // Eager initialization in constructor
77
+ this.initialize();
78
+ }
79
+
80
+ /**
81
+ * Render the view with route parameters
82
+ * Initializes the template and binds state
83
+ * @param params - Route parameters from the router
84
+ */
85
+ initialize(): void {
86
+ if (this._isInitialized) {
87
+ return;
88
+ }
89
+
90
+ // Add app-view class
91
+ this.classList.add('app-view');
92
+
93
+ // Initialize state
94
+ this._state = this.state();
95
+
96
+ // Bind template using container
97
+ this.binder = new TemplateBinder(
98
+ this._root,
99
+ this._state,
100
+ this._options.transitionClass
101
+ );
102
+ this.binder.autoUpdate = this._options.autoUpdate ?? true;
103
+
104
+ this.binder.bind();
105
+
106
+ this._isInitialized = true;
107
+ }
108
+
109
+ /**
110
+ * Get the custom element tag name for this class
111
+ * Converts class name to kebab-case (e.g., HomeView -> home-view)
112
+ */
113
+ static getTagName(): string {
114
+ return this.name
115
+ .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
116
+ .toLowerCase();
117
+ }
118
+
119
+ /**
120
+ * Register this class as a custom element
121
+ * Should be called before instantiation
122
+ */
123
+ static register(): void {
124
+ const tagName = this.getTagName();
125
+ if (!customElements.get(tagName)) {
126
+ customElements.define(tagName, this as any);
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Define the HTML template for this view
132
+ * Use Template.Ts syntax for data binding
133
+ * @returns HTML template string
134
+ */
135
+ protected abstract template(): string;
136
+
137
+ /**
138
+ * Define the initial state for this view
139
+ * State should include data and methods
140
+ * @returns Initial state object
141
+ */
142
+ protected abstract state(): TState;
143
+
144
+ /**
145
+ * Get the current state
146
+ */
147
+ protected get viewState(): TState {
148
+ if (!this._state) {
149
+ throw new Error('State not initialized. Call render() first.');
150
+ }
151
+ return this._state;
152
+ }
153
+
154
+ /**
155
+ * Get the route parameters passed to this view
156
+ */
157
+ protected get params(): RouteParams {
158
+ return this.app?.router?.currentParams || {};
159
+ }
160
+
161
+ /**
162
+ * Get the App instance by traversing up the DOM tree
163
+ */
164
+ protected get app(): App | null {
165
+ return App.fromElement(this);
166
+ }
167
+
168
+ /**
169
+ * Navigate to a specific path using the app's router
170
+ * @param path - The path to navigate to
171
+ */
172
+ protected navigate(path: string): void {
173
+ const appInstance = this.app;
174
+ if (appInstance) {
175
+ appInstance.navigate(path);
176
+ } else {
177
+ console.warn('Cannot navigate: App instance not found');
178
+ }
179
+ }
180
+
181
+ /**
182
+ * Update a single state value
183
+ * @param key - The state key to update
184
+ * @param value - The new value
185
+ */
186
+ protected setState<K extends keyof TState>(key: K, value: TState[K]): void {
187
+ if (!this._state) {
188
+ console.warn('Cannot set state before initialization');
189
+ return;
190
+ }
191
+
192
+ this._state[key] = value;
193
+
194
+ // Call lifecycle hook
195
+ if (this.onStateChanged) {
196
+ this.onStateChanged(key as string, value);
197
+ }
198
+
199
+ // Auto-update if enabled
200
+ if (this._options.autoUpdate && this.binder) {
201
+ this.update();
202
+ }
203
+ }
204
+
205
+ /**
206
+ * Update multiple state values at once
207
+ * @param updates - Object with state updates
208
+ */
209
+ protected setStates(updates: Partial<TState>): void {
210
+ if (!this._state) {
211
+ console.warn('Cannot set state before initialization');
212
+ return;
213
+ }
214
+
215
+ Object.assign(this._state, updates);
216
+
217
+ // Call lifecycle hooks for each update
218
+ if (this.onStateChanged) {
219
+ for (const [key, value] of Object.entries(updates)) {
220
+ this.onStateChanged(key, value);
221
+ }
222
+ }
223
+
224
+ // Auto-update if enabled
225
+ if (this._options.autoUpdate && this.binder) {
226
+ this.update();
227
+ }
228
+ }
229
+
230
+ /**
231
+ * Manually trigger a view update
232
+ * @param withAnimation - Whether to apply transition animation
233
+ */
234
+ public update(withAnimation: boolean = true): void {
235
+ if (this.binder) {
236
+ this.binder.update(withAnimation);
237
+ }
238
+ }
239
+
240
+ /**
241
+ * Update route parameters and re-trigger initialization logic
242
+ * Used when navigating to the same view with different parameters
243
+ * @param params - New route parameters
244
+ */
245
+ async updateParams(params: RouteParams = {}): Promise<void> {
246
+ // Call the parameter changed hook with old and new params
247
+ if (this.onParamsChanged) {
248
+ await this.onParamsChanged(params, this.params);
249
+ }
250
+
251
+ // Update the view if needed
252
+ if (this.binder && this._options.autoUpdate) {
253
+ this.update();
254
+ }
255
+ }
256
+
257
+ /**
258
+ * StackView lifecycle: called when view is about to be shown
259
+ */
260
+ async stackViewShowing(): Promise<void> {
261
+ // Call before mount hook
262
+ if (this.onBeforeMount) {
263
+ await this.onBeforeMount();
264
+ }
265
+ }
266
+
267
+ /**
268
+ * StackView lifecycle: called when view is about to be hidden
269
+ */
270
+ async stackViewHiding(): Promise<void> {
271
+ if (this.onBeforeUnmount) {
272
+ await this.onBeforeUnmount();
273
+ }
274
+ }
275
+
276
+ /**
277
+ * StackView lifecycle: called after view is hidden
278
+ */
279
+ async stackViewHidden(): Promise<void> {
280
+ // Destroy binder
281
+ if (this.binder) {
282
+ this.binder.destroy();
283
+ this.binder = null;
284
+ }
285
+
286
+ this._isInitialized = false;
287
+
288
+ if (this.onUnmounted) {
289
+ await this.onUnmounted();
290
+ }
291
+ }
292
+
293
+ /**
294
+ * Web Component lifecycle: called when connected to DOM
295
+ */
296
+ async connectedCallback(): Promise<void> {
297
+ // Initialize if not already done
298
+ if (!this._isInitialized) {
299
+ await this.initialize();
300
+ }
301
+
302
+ if (this.onMounted) {
303
+ await this.onMounted();
304
+ }
305
+ }
306
+
307
+ /**
308
+ * Web Component lifecycle: called when disconnected from DOM
309
+ */
310
+ disconnectedCallback(): void {
311
+ // Cleanup is handled by stackViewHidden
312
+ }
313
+
314
+ /**
315
+ * Check if the view is initialized
316
+ */
317
+ get isInitialized(): boolean {
318
+ return this._isInitialized;
319
+ }
320
+
321
+ // Lifecycle hooks (can be overridden by subclasses)
322
+ onBeforeMount?(): void | Promise<void>;
323
+ onMounted?(): void | Promise<void>;
324
+ onBeforeUnmount?(): void | Promise<void>;
325
+ onUnmounted?(): void | Promise<void>;
326
+ onStateChanged?(key: string, value: any): void;
327
+ onParamsChanged?(newParams: RouteParams, oldParams: RouteParams): void | Promise<void>;
328
+ }
329
+
330
+ export function Register(target: any) : any {
331
+ target.register();
332
+ return target;
333
+ }
@@ -0,0 +1,78 @@
1
+ import type { RouteParams } from '../navigation/types';
2
+
3
+ /**
4
+ * AppView lifecycle hooks
5
+ */
6
+ export interface AppViewLifecycle {
7
+ /**
8
+ * Called before the view is shown
9
+ * Use this to initialize data, fetch from APIs, etc.
10
+ */
11
+ onBeforeMount?(): void | Promise<void>;
12
+
13
+ /**
14
+ * Called after the view is mounted to the DOM
15
+ * Use this to set up event listeners, third-party libraries, etc.
16
+ */
17
+ onMounted?(): void | Promise<void>;
18
+
19
+ /**
20
+ * Called before the view is unmounted
21
+ * Use this to clean up resources, save state, etc.
22
+ */
23
+ onBeforeUnmount?(): void | Promise<void>;
24
+
25
+ /**
26
+ * Called after the view is unmounted from the DOM
27
+ * Final cleanup
28
+ */
29
+ onUnmounted?(): void | Promise<void>;
30
+
31
+ /**
32
+ * Called when the state is updated
33
+ * Use this to react to state changes
34
+ */
35
+ onStateChanged?(key: string, value: any): void;
36
+
37
+ /**
38
+ * Called when route parameters change while staying on the same view
39
+ * Use this to reload data based on new parameters (e.g., /user/1 -> /user/2)
40
+ * @param newParams - The new route parameters
41
+ * @param oldParams - The previous route parameters
42
+ */
43
+ onParamsChanged?(newParams: RouteParams, oldParams: RouteParams): void | Promise<void>;
44
+ }
45
+
46
+ /**
47
+ * AppView configuration options
48
+ */
49
+ export interface AppViewOptions {
50
+ /**
51
+ * Custom transition class for Template.Ts animations
52
+ * Default: 'transition-fade'
53
+ */
54
+ transitionClass?: string;
55
+
56
+ /**
57
+ * Whether to automatically update the view on state changes
58
+ * Default: true
59
+ */
60
+ autoUpdate?: boolean;
61
+
62
+ /**
63
+ * Whether to use Shadow DOM for the template
64
+ * When true, template is rendered in shadow root (enables <slot> to work properly)
65
+ * Default: false
66
+ */
67
+ useShadowDOM?: boolean;
68
+ }
69
+
70
+ /**
71
+ * AppView state object - can be any shape defined by the developer
72
+ */
73
+ export type AppViewState = Record<string, any>;
74
+
75
+ /**
76
+ * Template function that returns HTML string
77
+ */
78
+ export type TemplateFunction<T extends AppViewState = AppViewState> = (this: T) => string;
package/src/index.ts ADDED
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Main entry point for the application
3
+ * Export all public APIs
4
+ */
5
+
6
+ export { App } from './app/App';
7
+ export { AppView, Register } from './app/AppView';
8
+ export { Router } from './navigation/router';
9
+ export { Route } from './navigation/route';
10
+
11
+ // Export types
12
+ export type {
13
+ AppViewLifecycle,
14
+ AppViewOptions,
15
+ AppViewState,
16
+ TemplateFunction
17
+ } from './app/types';
18
+
19
+ export type {
20
+ RouteParams,
21
+ RouteGuard,
22
+ RouteOptions,
23
+ RouteHandler,
24
+ RouteDefinition,
25
+ NavigationEventDetail
26
+ } from './navigation/types';
27
+
28
+ export { NavigationEvents } from './navigation/types';
@@ -0,0 +1,87 @@
1
+ import type { RouteParams, RouteOptions } from './types';
2
+
3
+ /**
4
+ * Represents a route with path pattern matching and parameter extraction
5
+ */
6
+ export class Route {
7
+ public readonly path: string;
8
+ public readonly pattern: RegExp;
9
+ public readonly paramNames: string[];
10
+ public readonly options: RouteOptions;
11
+
12
+ constructor(path: string, options: RouteOptions = {}) {
13
+ this.path = path;
14
+ this.options = options;
15
+ this.paramNames = [];
16
+
17
+ // Convert path pattern to RegExp and extract parameter names
18
+ this.pattern = this.pathToRegExp(path);
19
+ }
20
+
21
+ /**
22
+ * Convert a path pattern like '/user/:id' to a RegExp
23
+ * Extracts parameter names like 'id'
24
+ */
25
+ private pathToRegExp(path: string): RegExp {
26
+ // Escape special characters except for :param patterns
27
+ const escapedPath = path.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
28
+
29
+ // Replace :param with capture groups and extract param names
30
+ const pattern = escapedPath.replace(/:([^/]+)/g, (match, paramName) => {
31
+ this.paramNames.push(paramName);
32
+ return '([^/]+)';
33
+ });
34
+
35
+ // Match exact path or with trailing slash
36
+ return new RegExp(`^${pattern}/?$`);
37
+ }
38
+
39
+ /**
40
+ * Check if the given path matches this route
41
+ * @param path - The path to test
42
+ * @returns The extracted parameters if match, null otherwise
43
+ */
44
+ match(path: string): RouteParams | null {
45
+ const match = this.pattern.exec(path);
46
+
47
+ if (!match) {
48
+ return null;
49
+ }
50
+
51
+ // Extract parameters from capture groups
52
+ const params: RouteParams = {};
53
+ this.paramNames.forEach((name, index) => {
54
+ params[name] = decodeURIComponent(match[index + 1]);
55
+ });
56
+
57
+ return params;
58
+ }
59
+
60
+ /**
61
+ * Check if navigation is allowed via the route guard
62
+ * @param params - Route parameters
63
+ * @returns true if allowed, false if denied, or redirect path
64
+ */
65
+ async canEnter(params: RouteParams): Promise<boolean | string> {
66
+ if (!this.options.canEnter) {
67
+ return true;
68
+ }
69
+
70
+ return await this.options.canEnter(params);
71
+ }
72
+
73
+ /**
74
+ * Generate a path from this route pattern with given parameters
75
+ * @param params - Parameters to inject into the path
76
+ * @returns The generated path
77
+ */
78
+ generate(params: RouteParams = {}): string {
79
+ let path = this.path;
80
+
81
+ for (const [key, value] of Object.entries(params)) {
82
+ path = path.replace(`:${key}`, encodeURIComponent(value));
83
+ }
84
+
85
+ return path;
86
+ }
87
+ }