@panyam/tsappkit 0.0.3

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,178 @@
1
+ import { EventBus, EventSubscriber, ComponentEventTypes } from './EventBus';
2
+ import { LCMComponent } from './LCMComponent';
3
+
4
+ /**
5
+ * Base interface for all UI components
6
+ * Enforces separation of concerns and standard lifecycle
7
+ */
8
+ export interface Component {
9
+ /**
10
+ * Unique identifier for this component instance
11
+ */
12
+ readonly componentId: string;
13
+
14
+ /**
15
+ * Root DOM element that this component owns and manages
16
+ */
17
+ readonly rootElement: HTMLElement;
18
+
19
+ /**
20
+ * Handle dynamic content updates (e.g., from HTMX or server responses)
21
+ * @param newHTML - New HTML content to replace current content
22
+ */
23
+ contentUpdated(newHTML: string): void;
24
+
25
+ /**
26
+ * Clean up the component and release resources
27
+ * Should unsubscribe from events, clean up DOM, and release memory
28
+ */
29
+ // destroy(): void;
30
+ }
31
+
32
+ /**
33
+ * Abstract base class implementing common component functionality
34
+ * Provides standard lifecycle management and event bus integration
35
+ *
36
+ * All components auto-initialize in constructor AND implement LCMComponent
37
+ * for coordination with other components when needed.
38
+ */
39
+ export abstract class BaseComponent implements Component, LCMComponent, EventSubscriber {
40
+ protected _eventBus: EventBus;
41
+
42
+ constructor(public readonly componentId: string, public readonly rootElement: HTMLElement, eventBus: EventBus | null = null, public readonly debugMode: boolean = false) {
43
+ // Mark as component in DOM for debugging
44
+ this._eventBus = eventBus || new EventBus();
45
+ this.rootElement.setAttribute('data-component', this.componentId);
46
+ }
47
+
48
+ public get eventBus(): EventBus {
49
+ return this._eventBus
50
+ }
51
+
52
+ public contentUpdated(newHTML: string): void {
53
+ this.log('Content updated, re-binding to DOM');
54
+
55
+ // Update the DOM
56
+ this.rootElement.innerHTML = newHTML;
57
+
58
+ // Note: In pure LCMComponent approach, re-binding should be handled
59
+ // by the component's LCMComponent lifecycle methods if needed
60
+ }
61
+
62
+ /**
63
+ * Subscribe to an event using the new EventSubscriber pattern
64
+ */
65
+ protected addSubscription(eventType: string, target: any = null): void {
66
+ this.eventBus.addSubscription(eventType, target, this);
67
+ }
68
+
69
+ /**
70
+ * Unsubscribe from an event using the new EventSubscriber pattern
71
+ */
72
+ protected removeSubscription(eventType: string, target: any = null): void {
73
+ this.eventBus.removeSubscription(eventType, target, this);
74
+ }
75
+
76
+ /**
77
+ * Emit an event from this component
78
+ */
79
+ protected emit<T = any>(eventType: string, data: T, target: any, emitter: any = null): void {
80
+ this.eventBus.emit(eventType, data, target, emitter || this);
81
+ }
82
+
83
+ /**
84
+ * Default implementation of EventSubscriber interface
85
+ * Components can override this to handle events
86
+ */
87
+ public handleBusEvent(eventType: string, data: any, target: any, emitter: any): void {
88
+ // Default: no event handling
89
+ // Subclasses should override this method to handle specific events
90
+ if (this.debugMode) {
91
+ console.log(`[${this.componentId}] Received unhandled event: ${eventType}`);
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Find elements within this component's root element only
97
+ * Enforces separation of concerns - no cross-component DOM access
98
+ */
99
+ protected findElement<T extends HTMLElement = HTMLElement>(selector: string): T | null {
100
+ return this.rootElement.querySelector<T>(selector);
101
+ }
102
+
103
+ /**
104
+ * Find multiple elements within this component's root element only
105
+ */
106
+ protected findElements<T extends HTMLElement = HTMLElement>(selector: string): T[] {
107
+ return Array.from(this.rootElement.querySelectorAll<T>(selector));
108
+ }
109
+
110
+ /**
111
+ * Log messages with component identification
112
+ */
113
+ protected log(message: string, data: any = null): void {
114
+ if (this.debugMode) {
115
+ console.log(`[${this.componentId}] ${message}`, data);
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Default lifecycle method: discover and return child components
121
+ * Override this if your component creates child components that need lifecycle management
122
+ */
123
+ public performLocalInit(): Promise<LCMComponent[]> | LCMComponent[] {
124
+ // Default: no child components
125
+ return [];
126
+ }
127
+
128
+ /**
129
+ * Default lifecycle method: inject dependencies
130
+ * Override this if your component needs dependencies from other components
131
+ */
132
+ public setupDependencies(): void | Promise<void> {
133
+ // Default: no dependencies needed
134
+ }
135
+
136
+ /**
137
+ * Default lifecycle method: activate component for coordination
138
+ * Override this if your component needs to coordinate with other components after initialization
139
+ */
140
+ public activate(): void | Promise<void> {
141
+ // Default: no coordination needed - component is already auto-initialized
142
+ }
143
+
144
+ /**
145
+ * Default lifecycle method: deactivate component
146
+ * Override this if your component needs cleanup during lifecycle management
147
+ */
148
+ public deactivate(): void | Promise<void> {
149
+ // Remove component marker from DOM
150
+ this.rootElement?.removeAttribute('data-component');
151
+
152
+ this.log('Component deactivated successfully');
153
+ }
154
+
155
+ set innerHTML(innerHTML: string) {
156
+ const [newHTML, allow] = this.shouldUpdateHtml(innerHTML)
157
+ if (allow) {
158
+ this.rootElement.innerHTML = newHTML
159
+ this.htmlUpdated(newHTML)
160
+ }
161
+ }
162
+
163
+ // Called BEFORE the HTML is about to be updated. This is an opportunity
164
+ // for subclasses to fix/tweak html being updated and also ignore an update
165
+ // just in case. Default behavior is to return the html as is
166
+ shouldUpdateHtml(html: string): [string, boolean] {
167
+ return [html, true]
168
+ }
169
+
170
+ // Called after the HTML for the component has been updated.
171
+ // This is an opportunity to rebind any event listeners etc
172
+ htmlUpdated(html: string) {
173
+ // Do nothing
174
+ }
175
+
176
+ // LCMComponent implementation with default empty methods
177
+ // Components can override these when they need coordination with other components
178
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Common DOM utility functions for consistent behavior across components
3
+ */
4
+
5
+ /**
6
+ * Checks if the user is currently typing in an input field, textarea, or other editable element.
7
+ * This is used to prevent keyboard shortcuts from interfering with user input.
8
+ *
9
+ * @param element - The target element from a keyboard event
10
+ * @returns true if the user is in an input context, false otherwise
11
+ */
12
+ export function isInInputContext(element: HTMLElement | null): boolean {
13
+ if (!element) return false;
14
+
15
+ const tagName = element.tagName.toLowerCase();
16
+
17
+ // Direct input elements
18
+ if (tagName === 'input' || tagName === 'textarea' || tagName === 'select') {
19
+ return true;
20
+ }
21
+
22
+ // Contenteditable elements
23
+ if (element.contentEditable === 'true' || element.isContentEditable) {
24
+ return true;
25
+ }
26
+
27
+ // Check if element is inside input-related containers
28
+ if (element.closest('input') !== null ||
29
+ element.closest('textarea') !== null ||
30
+ element.closest('select') !== null ||
31
+ element.closest('[contenteditable="true"]') !== null ||
32
+ element.closest('[contenteditable=""]') !== null) {
33
+ return true;
34
+ }
35
+
36
+ // Check if element is inside a modal (modals often contain forms)
37
+ if (element.closest('.modal') !== null) {
38
+ return true;
39
+ }
40
+
41
+ // Check for specific input field IDs that might be problematic
42
+ const inputFieldIds = ['map-title-input'];
43
+ if (inputFieldIds.includes(element.id) ||
44
+ inputFieldIds.some(id => element.closest(`#${id}`) !== null)) {
45
+ return true;
46
+ }
47
+
48
+ return false;
49
+ }
50
+
51
+ /**
52
+ * Checks if modifier keys are pressed (Ctrl, Alt, Cmd, Shift).
53
+ * This is commonly used to filter keyboard shortcuts.
54
+ *
55
+ * @param event - The keyboard event
56
+ * @returns true if any modifier keys are pressed, false otherwise
57
+ */
58
+ export function hasModifierKeys(event: KeyboardEvent): boolean {
59
+ return event.ctrlKey || event.altKey || event.metaKey || event.shiftKey;
60
+ }
61
+
62
+ /**
63
+ * Combined check for whether keyboard shortcuts should be ignored.
64
+ * This checks both modifier keys and input context.
65
+ *
66
+ * @param event - The keyboard event
67
+ * @returns true if shortcuts should be ignored, false if they can be processed
68
+ */
69
+ export function shouldIgnoreShortcut(event: KeyboardEvent): boolean {
70
+ const target = event.target as HTMLElement;
71
+ return hasModifierKeys(event) || isInInputContext(target);
72
+ }
@@ -0,0 +1,272 @@
1
+ /**
2
+ * Simple event system for component communication
3
+ * Provides error isolation and idempotent subscriptions
4
+ */
5
+
6
+ /**
7
+ * Simple event handler function type
8
+ */
9
+ export type EventHandler = (data?: any) => void;
10
+
11
+ /**
12
+ * Interface for components that want to receive events via the EventBus
13
+ */
14
+ export interface EventSubscriber {
15
+ /**
16
+ * Handle incoming events from the EventBus
17
+ * @param eventType - The type of event being handled
18
+ * @param data - The event data payload
19
+ * @param subject - The subject/subject entity (what the event is about)
20
+ * @param emitter - The entity that emitted the event
21
+ */
22
+ handleBusEvent(eventType: string, data: any, subject: any, emitter: any): void;
23
+ }
24
+
25
+ /**
26
+ * Internal wrapper to adapt a simple function handler to EventSubscriber interface
27
+ */
28
+ class FunctionSubscriber implements EventSubscriber {
29
+ constructor(public readonly handler: EventHandler) {}
30
+
31
+ handleBusEvent(eventType: string, data: any, subject: any, emitter: any): void {
32
+ this.handler(data);
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Synchronous event bus for component communication
38
+ * Features:
39
+ * - Type-safe event names and payloads
40
+ * - Error isolation (one handler failure doesn't stop others)
41
+ * - Source exclusion (events not sent back to source)
42
+ * - Debug logging for troubleshooting
43
+ */
44
+ export class EventBus {
45
+ private subscribers: Map<string, Set<EventSubscriber>> = new Map();
46
+ private functionSubscribers: Map<string, Map<EventHandler, FunctionSubscriber>> = new Map();
47
+ private onceHandlers: Map<string, Set<FunctionSubscriber>> = new Map();
48
+ private debugMode: boolean = false;
49
+
50
+ constructor(debugMode: boolean = false) {
51
+ this.debugMode = debugMode;
52
+ }
53
+
54
+ // ============== Simple Function-Based API ==============
55
+
56
+ /**
57
+ * Subscribe to an event with a simple handler function
58
+ */
59
+ public on(eventType: string, handler: EventHandler): void {
60
+ if (!this.functionSubscribers.has(eventType)) {
61
+ this.functionSubscribers.set(eventType, new Map());
62
+ }
63
+
64
+ const handlersMap = this.functionSubscribers.get(eventType)!;
65
+ if (handlersMap.has(handler)) {
66
+ if (this.debugMode) {
67
+ console.log(`[EventBus] Handler already registered for '${eventType}'`);
68
+ }
69
+ return;
70
+ }
71
+
72
+ const subscriber = new FunctionSubscriber(handler);
73
+ handlersMap.set(handler, subscriber);
74
+ this.addSubscription(eventType, null, subscriber);
75
+ }
76
+
77
+ /**
78
+ * Unsubscribe a handler function from an event
79
+ */
80
+ public off(eventType: string, handler: EventHandler): void {
81
+ const handlersMap = this.functionSubscribers.get(eventType);
82
+ if (!handlersMap) return;
83
+
84
+ const subscriber = handlersMap.get(handler);
85
+ if (subscriber) {
86
+ handlersMap.delete(handler);
87
+ this.removeSubscription(eventType, null, subscriber);
88
+
89
+ if (handlersMap.size === 0) {
90
+ this.functionSubscribers.delete(eventType);
91
+ }
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Subscribe to an event for one-time execution
97
+ */
98
+ public once(eventType: string, handler: EventHandler): void {
99
+ if (!this.onceHandlers.has(eventType)) {
100
+ this.onceHandlers.set(eventType, new Set());
101
+ }
102
+
103
+ const subscriber = new FunctionSubscriber(handler);
104
+ this.onceHandlers.get(eventType)!.add(subscriber);
105
+
106
+ if (this.debugMode) {
107
+ console.log(`[EventBus] Added once handler for '${eventType}'`);
108
+ }
109
+ }
110
+
111
+ // ============== EventSubscriber-Based API ==============
112
+
113
+ /**
114
+ * Add a subscription using the EventSubscriber pattern
115
+ * Provides automatic idempotency - same subscriber object won't be added twice
116
+ */
117
+ public addSubscription(eventType: string, subject: any, subscriber: EventSubscriber): void {
118
+ if (!this.subscribers.has(eventType)) {
119
+ this.subscribers.set(eventType, new Set());
120
+ }
121
+
122
+ const subscribers = this.subscribers.get(eventType)!;
123
+ const wasAdded = !subscribers.has(subscriber);
124
+
125
+ if (wasAdded) {
126
+ subscribers.add(subscriber);
127
+ if (this.debugMode) {
128
+ console.log(`[EventBus] Added subscription to '${eventType}' for ${subscriber.constructor.name}`);
129
+ }
130
+ } else if (this.debugMode) {
131
+ console.log(`[EventBus] Subscription already exists for '${eventType}' and ${subscriber.constructor.name}`);
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Remove a subscription using the EventSubscriber pattern
137
+ */
138
+ public removeSubscription(eventType: string, subject: any, subscriber: EventSubscriber): void {
139
+ const subscribers = this.subscribers.get(eventType);
140
+
141
+ if (subscribers) {
142
+ const wasRemoved = subscribers.delete(subscriber);
143
+
144
+ if (this.debugMode && wasRemoved) {
145
+ console.log(`[EventBus] Removed subscription from '${eventType}' for ${subscriber.constructor.name}`);
146
+ }
147
+
148
+ // Clean up empty subscription sets
149
+ if (subscribers.size === 0) {
150
+ this.subscribers.delete(eventType);
151
+ }
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Emit an event to all subscribers
157
+ * @param eventType - The event type to emit
158
+ * @param data - The event data payload
159
+ * @param subject - The subject/subject entity that this event relates to (optional for simple API)
160
+ * @param emitter - The entity that emitted the event (optional for simple API)
161
+ */
162
+ public emit<T = any>(eventType: string, data?: T, subject?: any, emitter?: any): void {
163
+ const subscribers = this.subscribers.get(eventType);
164
+ const onceHandlers = this.onceHandlers.get(eventType);
165
+
166
+ const subscriberCount = (subscribers?.size || 0) + (onceHandlers?.size || 0);
167
+ if (subscriberCount === 0) {
168
+ return;
169
+ }
170
+
171
+ if (this.debugMode) {
172
+ console.log(`[EventBus] Emitting '${eventType}' to ${subscriberCount} subscribers`);
173
+ }
174
+
175
+ let successCount = 0;
176
+ let errorCount = 0;
177
+
178
+ // Call regular EventSubscriber handlers with error isolation
179
+ if (subscribers) {
180
+ subscribers.forEach(subscriber => {
181
+ try {
182
+ subscriber.handleBusEvent(eventType, data, subject, emitter);
183
+ successCount++;
184
+ } catch (error) {
185
+ errorCount++;
186
+ console.error(
187
+ `[EventBus] Error in EventSubscriber handler for '${eventType}' ` +
188
+ `in subscriber '${subscriber.constructor.name}':`,
189
+ error
190
+ );
191
+ }
192
+ });
193
+ }
194
+
195
+ // Call one-time handlers and then remove them
196
+ if (onceHandlers && onceHandlers.size > 0) {
197
+ onceHandlers.forEach(subscriber => {
198
+ try {
199
+ subscriber.handleBusEvent(eventType, data, subject, emitter);
200
+ successCount++;
201
+ } catch (error) {
202
+ errorCount++;
203
+ console.error(
204
+ `[EventBus] Error in once handler for '${eventType}':`,
205
+ error
206
+ );
207
+ }
208
+ });
209
+ // Clear once handlers after execution
210
+ this.onceHandlers.delete(eventType);
211
+ }
212
+
213
+ if (this.debugMode) {
214
+ console.log(
215
+ `[EventBus] Event '${eventType}' completed: ` +
216
+ `${successCount} success, ${errorCount} errors`
217
+ );
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Get all event types that have subscribers
223
+ */
224
+ public getEventTypes(): string[] {
225
+ return Array.from(this.subscribers.keys());
226
+ }
227
+
228
+ /**
229
+ * Get subscriber count for an event type
230
+ */
231
+ public getSubscriberCount(eventType: string): number {
232
+ return this.subscribers.get(eventType)?.size || 0;
233
+ }
234
+
235
+ /**
236
+ * Clear all subscriptions (useful for cleanup)
237
+ */
238
+ public clear(): void {
239
+ this.subscribers.clear();
240
+ this.functionSubscribers.clear();
241
+ this.onceHandlers.clear();
242
+ if (this.debugMode) {
243
+ console.log('[EventBus] All subscriptions cleared');
244
+ }
245
+ }
246
+
247
+ /**
248
+ * Enable or disable debug logging
249
+ */
250
+ public setDebugMode(enabled: boolean): void {
251
+ this.debugMode = enabled;
252
+ }
253
+ }
254
+
255
+ export const ComponentEventTypes= {
256
+ COMPONENT_INITIALIZED: 'component-initialized',
257
+ COMPONENT_HYDRATED: 'component-hydrated',
258
+ COMPONENT_ERROR: 'component-error'
259
+ } as const;
260
+
261
+ export const LifecycleEventTypes = {
262
+ LOCAL_INIT_STARTED: "lifecycle-local-init-started",
263
+ LOCAL_INIT_FINISHED: "lifecycle-local-init-finished",
264
+ DEPENDENCIES_INJECTED: "lifecycle-dependencies-injected",
265
+ ACTIVATION_STARTED: "lifecycle-activation-started",
266
+ ACTIVATION_FINISHED: "lifecycle-activation-finished",
267
+ DEACTIVATION_STARTED: "lifecycle-deactivation-started",
268
+ DEACTIVATION_FINISHED: "lifecycle-deactivation-finished",
269
+ } as const;
270
+
271
+ export type LifecycleEventType = typeof LifecycleEventTypes[keyof typeof LifecycleEventTypes];
272
+ export type ComponentEventType = typeof ComponentEventTypes[keyof typeof ComponentEventTypes];