@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,435 @@
1
+ /**
2
+ * Generic keyboard shortcut manager for handling multi-key commands
3
+ * across all application pages
4
+ *
5
+ * Current Behavior (default):
6
+ * - User types 'c3' → visual indicator → Enter/timeout → execute
7
+ * - Requires explicit confirmation for execution
8
+ *
9
+ * Future Immediate Mode (when enabled):
10
+ * - User types 'c3' → immediate preview → Escape to cancel
11
+ * - Provides instant feedback with option to cancel
12
+ */
13
+
14
+ import { isInInputContext } from './DOMUtils';
15
+
16
+ export interface ShortcutConfig {
17
+ key: string;
18
+ handler: (args?: string) => void;
19
+ description: string;
20
+ category?: string;
21
+ requiresArgs?: boolean;
22
+ argType?: 'number' | 'string';
23
+ contextFilter?: (event: KeyboardEvent) => boolean;
24
+
25
+ // Future: Preview handlers for immediate execution mode
26
+ previewHandler?: (args?: string) => void; // Called immediately as user types
27
+ cancelHandler?: () => void; // Called when user presses Escape
28
+ executeImmediately?: boolean; // Override global immediateExecution setting
29
+ }
30
+
31
+ export interface ShortcutManagerConfig {
32
+ shortcuts: ShortcutConfig[];
33
+ helpContainer?: string;
34
+ timeout?: number; // ms to return to normal state (current: 3000ms, immediate mode: 300ms)
35
+ immediateExecution?: boolean; // Enable immediate execution with preview
36
+ previewDelay?: number; // ms delay before preview execution (default: 300ms)
37
+ onStateChange?: (state: KeyboardState, command?: string) => void; // Callback when state changes
38
+ }
39
+
40
+ export enum KeyboardState {
41
+ NORMAL = 'normal',
42
+ AWAITING_ARGS = 'awaiting_args'
43
+ }
44
+
45
+ export class KeyboardShortcutManager {
46
+ private shortcuts: Map<string, ShortcutConfig> = new Map();
47
+ private state: KeyboardState = KeyboardState.NORMAL;
48
+ private currentCommand: string = '';
49
+ private currentArgs: string = '';
50
+ private helpContainer: string | null = null;
51
+ private timeout: number = 3000; // Default 3 second timeout
52
+ private timeoutId: number | null = null;
53
+ private helpOverlay: HTMLElement | null = null;
54
+ private immediateExecution: boolean = false; // Enable immediate execution mode
55
+ private previewDelay: number = 300; // Default 300ms preview delay
56
+ private previewTimeoutId: number | null = null; // Separate timeout for preview
57
+ private isPreviewActive: boolean = false; // Track if preview is currently active
58
+ private onStateChange: ((state: KeyboardState, command?: string) => void) | null = null;
59
+
60
+ constructor(config: ShortcutManagerConfig) {
61
+ this.helpContainer = config.helpContainer || null;
62
+ this.timeout = config.timeout || 3000;
63
+ this.immediateExecution = config.immediateExecution || false;
64
+ this.previewDelay = config.previewDelay || 300;
65
+ this.onStateChange = config.onStateChange || null;
66
+
67
+ // Register shortcuts
68
+ config.shortcuts.forEach(shortcut => {
69
+ this.shortcuts.set(shortcut.key, shortcut);
70
+ });
71
+
72
+ this.initialize();
73
+ }
74
+
75
+ private initialize(): void {
76
+ // Global keydown listener
77
+ document.addEventListener('keydown', this.handleKeydown.bind(this));
78
+
79
+ // Show initial state indicator
80
+ this.updateStateIndicator();
81
+ }
82
+
83
+ private handleKeydown(event: KeyboardEvent): void {
84
+ const target = event.target as HTMLElement;
85
+
86
+ // Skip if in input field, textarea, or contenteditable
87
+ if (isInInputContext(target)) {
88
+ return;
89
+ }
90
+
91
+ // Handle help key
92
+ if (event.key === '?' && this.state === KeyboardState.NORMAL) {
93
+ event.preventDefault();
94
+ this.showHelp();
95
+ return;
96
+ }
97
+
98
+ // Handle escape key
99
+ if (event.key === 'Escape') {
100
+ event.preventDefault();
101
+
102
+ // Cancel preview if active in immediate execution mode
103
+ if (this.immediateExecution && this.isPreviewActive) {
104
+ this.cancelCurrentPreview();
105
+ }
106
+
107
+ this.resetState();
108
+ return;
109
+ }
110
+
111
+ // Handle state machine
112
+ if (this.state === KeyboardState.NORMAL) {
113
+ this.handleNormalState(event);
114
+ } else if (this.state === KeyboardState.AWAITING_ARGS) {
115
+ this.handleAwaitingArgsState(event);
116
+ }
117
+ }
118
+
119
+ private handleNormalState(event: KeyboardEvent): void {
120
+ const key = event.key.toLowerCase();
121
+ const shortcut = this.shortcuts.get(key);
122
+
123
+ if (shortcut) {
124
+ event.preventDefault();
125
+
126
+ // Check context filter
127
+ if (shortcut.contextFilter && !shortcut.contextFilter(event)) {
128
+ return;
129
+ }
130
+
131
+ if (shortcut.requiresArgs) {
132
+ // Enter args waiting state
133
+ this.state = KeyboardState.AWAITING_ARGS;
134
+ this.currentCommand = key;
135
+ this.currentArgs = '';
136
+ this.updateStateIndicator();
137
+ this.startTimeout();
138
+
139
+ // Notify state change
140
+ if (this.onStateChange) {
141
+ this.onStateChange(this.state, key);
142
+ }
143
+ } else {
144
+ // Execute immediately
145
+ this.executeShortcut(shortcut);
146
+ }
147
+ }
148
+ }
149
+
150
+ private handleAwaitingArgsState(event: KeyboardEvent): void {
151
+ const key = event.key;
152
+
153
+ if (key >= '0' && key <= '9') {
154
+ // Add digit to args
155
+ event.preventDefault();
156
+ this.currentArgs += key;
157
+ this.updateStateIndicator();
158
+ this.resetTimeout();
159
+
160
+ // Handle immediate execution mode
161
+ if (this.immediateExecution) {
162
+ this.schedulePreviewExecution();
163
+ }
164
+ } else if (key === 'Enter' || key === ' ') {
165
+ // Execute command with args
166
+ event.preventDefault();
167
+ this.executeCurrentCommand();
168
+ } else if (key === 'Backspace') {
169
+ // Remove last digit
170
+ event.preventDefault();
171
+ this.currentArgs = this.currentArgs.slice(0, -1);
172
+ this.updateStateIndicator();
173
+ this.resetTimeout();
174
+
175
+ // Handle immediate execution mode
176
+ if (this.immediateExecution) {
177
+ this.cancelPreviewExecution();
178
+ if (this.currentArgs.length > 0) {
179
+ this.schedulePreviewExecution();
180
+ }
181
+ }
182
+ }
183
+ }
184
+
185
+ private executeCurrentCommand(): void {
186
+ const shortcut = this.shortcuts.get(this.currentCommand);
187
+ if (shortcut && this.currentArgs) {
188
+ this.executeShortcut(shortcut, this.currentArgs);
189
+ }
190
+ this.resetState();
191
+ }
192
+
193
+ private executeShortcut(shortcut: ShortcutConfig, args?: string): void {
194
+ try {
195
+ shortcut.handler(args);
196
+ } catch (error) {
197
+ console.error('Error executing shortcut:', error);
198
+ }
199
+ }
200
+
201
+ private schedulePreviewExecution(): void {
202
+ // Cancel any existing preview timeout
203
+ this.cancelPreviewExecution();
204
+
205
+ // Schedule preview execution
206
+ this.previewTimeoutId = window.setTimeout(() => {
207
+ this.executePreview();
208
+ }, this.previewDelay);
209
+ }
210
+
211
+ private cancelPreviewExecution(): void {
212
+ if (this.previewTimeoutId) {
213
+ window.clearTimeout(this.previewTimeoutId);
214
+ this.previewTimeoutId = null;
215
+ }
216
+
217
+ // Cancel current preview if active
218
+ if (this.isPreviewActive) {
219
+ this.cancelCurrentPreview();
220
+ }
221
+ }
222
+
223
+ private executePreview(): void {
224
+ const shortcut = this.shortcuts.get(this.currentCommand);
225
+ if (shortcut && this.currentArgs) {
226
+ try {
227
+ // Call preview handler if available
228
+ if (shortcut.previewHandler) {
229
+ shortcut.previewHandler(this.currentArgs);
230
+ this.isPreviewActive = true;
231
+ } else {
232
+ // Fallback to regular handler for immediate execution
233
+ shortcut.handler(this.currentArgs);
234
+ this.isPreviewActive = true;
235
+ }
236
+ } catch (error) {
237
+ console.error('Error executing preview:', error);
238
+ }
239
+ }
240
+ }
241
+
242
+ private cancelCurrentPreview(): void {
243
+ if (this.isPreviewActive) {
244
+ const shortcut = this.shortcuts.get(this.currentCommand);
245
+ if (shortcut && shortcut.cancelHandler) {
246
+ try {
247
+ shortcut.cancelHandler();
248
+ } catch (error) {
249
+ console.error('Error canceling preview:', error);
250
+ }
251
+ }
252
+ this.isPreviewActive = false;
253
+ }
254
+ }
255
+
256
+
257
+ private resetState(): void {
258
+ this.state = KeyboardState.NORMAL;
259
+ this.currentCommand = '';
260
+ this.currentArgs = '';
261
+ this.clearTimeout();
262
+ this.cancelPreviewExecution();
263
+ this.updateStateIndicator();
264
+ this.hideHelp();
265
+
266
+ // Notify state change
267
+ if (this.onStateChange) {
268
+ this.onStateChange(this.state);
269
+ }
270
+ }
271
+
272
+ private startTimeout(): void {
273
+ this.timeoutId = window.setTimeout(() => {
274
+ this.resetState();
275
+ }, this.timeout);
276
+ }
277
+
278
+ private resetTimeout(): void {
279
+ this.clearTimeout();
280
+ this.startTimeout();
281
+ }
282
+
283
+ private clearTimeout(): void {
284
+ if (this.timeoutId) {
285
+ window.clearTimeout(this.timeoutId);
286
+ this.timeoutId = null;
287
+ }
288
+ }
289
+
290
+ private updateStateIndicator(): void {
291
+ // Remove existing indicator
292
+ const existingIndicator = document.getElementById('keyboard-state-indicator');
293
+ if (existingIndicator) {
294
+ existingIndicator.remove();
295
+ }
296
+
297
+ // Only show indicator when not in normal state
298
+ if (this.state === KeyboardState.NORMAL) {
299
+ return;
300
+ }
301
+
302
+ // Create new indicator
303
+ const indicator = document.createElement('div');
304
+ indicator.id = 'keyboard-state-indicator';
305
+ indicator.className = 'fixed top-4 right-4 bg-blue-600 text-white px-3 py-2 rounded-lg shadow-lg z-50 font-mono text-sm';
306
+
307
+ if (this.state === KeyboardState.AWAITING_ARGS) {
308
+ const shortcut = this.shortcuts.get(this.currentCommand);
309
+ const description = shortcut ? shortcut.description : 'Unknown command';
310
+ indicator.innerHTML = `
311
+ <div class="flex items-center space-x-2">
312
+ <span>${this.currentCommand.toUpperCase()}</span>
313
+ <span class="text-blue-200">${this.currentArgs || '_'}</span>
314
+ </div>
315
+ <div class="text-xs text-blue-200 mt-1">${description}</div>
316
+ `;
317
+ }
318
+
319
+ document.body.appendChild(indicator);
320
+ }
321
+
322
+ private showHelp(): void {
323
+ if (this.helpOverlay) {
324
+ this.hideHelp();
325
+ return;
326
+ }
327
+
328
+ this.helpOverlay = document.createElement('div');
329
+ this.helpOverlay.id = 'keyboard-help-overlay';
330
+ this.helpOverlay.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50';
331
+
332
+ const helpContent = document.createElement('div');
333
+ helpContent.className = 'bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-2xl max-h-[80vh] overflow-y-auto p-6';
334
+
335
+ helpContent.innerHTML = this.generateHelpContent();
336
+
337
+ this.helpOverlay.appendChild(helpContent);
338
+ document.body.appendChild(this.helpOverlay);
339
+
340
+ // Close on click outside or escape
341
+ this.helpOverlay.addEventListener('click', (e) => {
342
+ if (e.target === this.helpOverlay) {
343
+ this.hideHelp();
344
+ }
345
+ });
346
+ }
347
+
348
+ private hideHelp(): void {
349
+ if (this.helpOverlay) {
350
+ this.helpOverlay.remove();
351
+ this.helpOverlay = null;
352
+ }
353
+ }
354
+
355
+ private generateHelpContent(): string {
356
+ const categories = new Map<string, ShortcutConfig[]>();
357
+
358
+ // Group shortcuts by category
359
+ this.shortcuts.forEach(shortcut => {
360
+ const category = shortcut.category || 'General';
361
+ if (!categories.has(category)) {
362
+ categories.set(category, []);
363
+ }
364
+ categories.get(category)!.push(shortcut);
365
+ });
366
+
367
+ let html = `
368
+ <div class="flex items-center justify-between mb-4">
369
+ <h2 class="text-xl font-bold text-gray-900 dark:text-white">Keyboard Shortcuts</h2>
370
+ <button class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200" onclick="this.closest('#keyboard-help-overlay').remove()">
371
+ <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
372
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
373
+ </svg>
374
+ </button>
375
+ </div>
376
+ `;
377
+
378
+ categories.forEach((shortcuts, category) => {
379
+ html += `
380
+ <div class="mb-6">
381
+ <h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-3">${category}</h3>
382
+ <div class="space-y-2">
383
+ `;
384
+
385
+ shortcuts.forEach(shortcut => {
386
+ const keyDisplay = shortcut.requiresArgs
387
+ ? `${shortcut.key.toUpperCase()}<span class="text-blue-600 dark:text-blue-400">&lt;number&gt;</span>`
388
+ : shortcut.key.toUpperCase();
389
+
390
+ html += `
391
+ <div class="flex items-center justify-between py-2 px-3 bg-gray-50 dark:bg-gray-700 rounded">
392
+ <span class="text-sm text-gray-700 dark:text-gray-300">${shortcut.description}</span>
393
+ <kbd class="px-2 py-1 text-xs font-mono bg-gray-200 dark:bg-gray-600 text-gray-800 dark:text-gray-200 rounded">${keyDisplay}</kbd>
394
+ </div>
395
+ `;
396
+ });
397
+
398
+ html += `
399
+ </div>
400
+ </div>
401
+ `;
402
+ });
403
+
404
+
405
+ html += `
406
+ <div class="mt-6 pt-4 border-t border-gray-200 dark:border-gray-600">
407
+ <p class="text-sm text-gray-600 dark:text-gray-400 text-center">
408
+ Press <kbd class="px-2 py-1 text-xs font-mono bg-gray-200 dark:bg-gray-600 rounded">ESC</kbd> to cancel any command or
409
+ <kbd class="px-2 py-1 text-xs font-mono bg-gray-200 dark:bg-gray-600 rounded">?</kbd> to close this help
410
+ </p>
411
+ </div>
412
+ `;
413
+
414
+ return html;
415
+ }
416
+
417
+ public destroy(): void {
418
+ document.removeEventListener('keydown', this.handleKeydown.bind(this));
419
+ this.cancelPreviewExecution();
420
+ this.resetState();
421
+ this.hideHelp();
422
+ }
423
+
424
+ public getState(): KeyboardState {
425
+ return this.state;
426
+ }
427
+
428
+ public getCurrentCommand(): string {
429
+ return this.currentCommand;
430
+ }
431
+
432
+ public getCurrentArgs(): string {
433
+ return this.currentArgs;
434
+ }
435
+ }
@@ -0,0 +1,140 @@
1
+ /**
2
+ * LCMComponent Interface - Defines multi-phase component initialization
3
+ *
4
+ * This interface implements a breadth-first lifecycle pattern that eliminates
5
+ * initialization order dependencies and race conditions through synchronization barriers.
6
+ *
7
+ * Lifecycle Phases:
8
+ * 1. Construction - Components are created but not initialized
9
+ * 2. bindToDOM() - Basic DOM setup, discover child components
10
+ * 3. injectDependencies() - Receive references to other components
11
+ * 4. activate() - Final setup when all dependencies are ready
12
+ *
13
+ * Key Benefits:
14
+ * - Order Independence: Components can be created in any sequence
15
+ * - Async Safety: Each phase waits for all components before proceeding
16
+ * - Clear Dependencies: Explicit injection points prevent race conditions
17
+ * - Error Isolation: Component failures don't cascade to others
18
+ */
19
+
20
+ /**
21
+ * LCM Component - Short for LifeCycle Managed Component
22
+ * enable components to be declared first and their loading be managed by a
23
+ * LifecycleController so that we have layered creation, dependency injection and setup
24
+ * in a breadth first way.
25
+ *
26
+ * A key constraint on LCMComponents are that they should not perform any initialization
27
+ * in the constructor. This is because a LifecycleController should be used to load/setup
28
+ * these components and they will follow a layered approach. Performing these actions in
29
+ * the constructor could violate the idempotency guarantees.
30
+ */
31
+ export interface LCMComponent {
32
+ /**
33
+ * Phase 1: The "local" initialization of the component.
34
+ *
35
+ * In this phase the component initializes itself and returns any children it might
36
+ * want initialized as part of the lifecycled loading.
37
+ *
38
+ * This phase should:
39
+ * - Set up basic DOM elements and event listeners
40
+ * - Create child components (but don't initialize them) and return them
41
+ * - Return array of child components for lifecycle controller discovery
42
+ *
43
+ * This phase must be synchronous and should not:
44
+ * - Access other components or external dependencies
45
+ * - Perform async operations
46
+ * - Emit events or notifications
47
+ *
48
+ * @returns Array of child components to be managed by lifecycle controller
49
+ */
50
+ performLocalInit(): Promise<LCMComponent[]> | LCMComponent[];
51
+
52
+ /**
53
+ * Phase 2: Inject dependencies from parent/siblings
54
+ *
55
+ * This phase should:
56
+ * - Receive and store references to required dependencies
57
+ * - Validate that required dependencies are provided
58
+ * - Set up internal state based on dependencies
59
+ *
60
+ * This phase can be async and may:
61
+ * - Load external data or resources
62
+ * - Perform validation or setup operations
63
+ * - Initialize internal components that depend on injected references
64
+ *
65
+ * @param deps Record of dependency name to dependency instance
66
+ * @returns Promise<void> or void - can be async
67
+ */
68
+ setupDependencies(): Promise<void> | void;
69
+
70
+ /**
71
+ * Phase 3: Activate component when all dependencies are ready
72
+ *
73
+ * This phase should:
74
+ * - Complete final initialization
75
+ * - Enable component functionality
76
+ * - Start listening for external events
77
+ * - Begin normal operation
78
+ *
79
+ * This phase can be async and may:
80
+ * - Connect to external services
81
+ * - Load initial data
82
+ * - Emit ready notifications
83
+ *
84
+ * @returns Promise<void> or void - can be async
85
+ */
86
+ activate(): Promise<void> | void;
87
+
88
+ /**
89
+ * Cleanup phase: Deactivate component and clean up resources
90
+ *
91
+ * This should:
92
+ * - Stop all ongoing operations
93
+ * - Remove event listeners
94
+ * - Clean up external connections
95
+ * - Dispose of child components
96
+ *
97
+ * @returns Promise<void> or void - can be async
98
+ */
99
+ deactivate(): Promise<void> | void;
100
+ }
101
+
102
+ /**
103
+ * Configuration for component lifecycle behavior
104
+ */
105
+ export interface LCMComponentConfig {
106
+ /**
107
+ * Maximum time to wait for a lifecycle phase to complete (ms)
108
+ * Default: 10000 (10 seconds)
109
+ */
110
+ phaseTimeoutMs?: number;
111
+
112
+ /**
113
+ * Whether to continue if individual components fail during a phase
114
+ * Default: false (fail fast)
115
+ */
116
+ continueOnError?: boolean;
117
+
118
+ /**
119
+ * Whether to validate dependencies against declared requirements
120
+ * Default: true
121
+ */
122
+ validateDependencies?: boolean;
123
+
124
+ /**
125
+ * Enable debug logging for lifecycle phases
126
+ * Default: false
127
+ */
128
+ enableDebugLogging?: boolean;
129
+ }
130
+
131
+ /**
132
+ * Event emitted during component lifecycle transitions
133
+ */
134
+ export interface LCMComponentEvent {
135
+ type: 'phase-start' | 'phase-complete' | 'phase-error' | 'component-ready';
136
+ componentName: string;
137
+ timestamp: number;
138
+ error?: Error;
139
+ metadata?: Record<string, any>;
140
+ }