@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.
- package/README.md +98 -0
- package/dist/index.d.mts +866 -0
- package/dist/index.d.ts +866 -0
- package/dist/index.js +1736 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +1718 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +43 -0
- package/src/BasePage.ts +270 -0
- package/src/Component.ts +178 -0
- package/src/DOMUtils.ts +72 -0
- package/src/EventBus.ts +272 -0
- package/src/KeyboardShortcutManager.ts +435 -0
- package/src/LCMComponent.ts +140 -0
- package/src/LifecycleController.ts +213 -0
- package/src/MobileBottomDrawer.ts +236 -0
- package/src/Modal.ts +233 -0
- package/src/SplashScreen.ts +111 -0
- package/src/TemplateLoader.ts +101 -0
- package/src/ThemeManager.ts +100 -0
- package/src/ToastManager.ts +161 -0
- package/src/events.ts +5 -0
- package/src/index.ts +39 -0
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
|
|
2
|
+
import {
|
|
3
|
+
LCMComponent,
|
|
4
|
+
LCMComponentConfig,
|
|
5
|
+
LCMComponentEvent,
|
|
6
|
+
} from './LCMComponent';
|
|
7
|
+
import { EventBus, LifecycleEventTypes, LifecycleEventType } from './EventBus';
|
|
8
|
+
import { ComponentLifecycleEvent } from './events';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* LifecycleController - Orchestrates breadth-first component initialization
|
|
12
|
+
*
|
|
13
|
+
* This controller implements a breadth-first traversal of the component tree
|
|
14
|
+
* followed by phase-wise initialization with synchronization barriers.
|
|
15
|
+
*
|
|
16
|
+
* Process:
|
|
17
|
+
* 1. Discovery Phase: Traverse component tree to find all components
|
|
18
|
+
* 2. DOM Binding Phase: All components bind to DOM simultaneously
|
|
19
|
+
* 3. Dependency Injection Phase: All components receive dependencies
|
|
20
|
+
* 4. Activation Phase: All components complete initialization
|
|
21
|
+
*
|
|
22
|
+
* Each phase acts as a synchronization barrier - no component can proceed
|
|
23
|
+
* to the next phase until ALL components have completed the current phase.
|
|
24
|
+
*/
|
|
25
|
+
export class LifecycleController {
|
|
26
|
+
private allComponents: LCMComponent[];
|
|
27
|
+
private componentsByLevel: LCMComponent[][];
|
|
28
|
+
private config: Required<LCMComponentConfig>;
|
|
29
|
+
public static DefaultConfig: LCMComponentConfig = {
|
|
30
|
+
enableDebugLogging: true,
|
|
31
|
+
phaseTimeoutMs: 15000, // Increased timeout for component loading
|
|
32
|
+
continueOnError: false // Fail fast for debugging
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
constructor(protected eventBus: EventBus, config: LCMComponentConfig = {}) {
|
|
36
|
+
this.config = {
|
|
37
|
+
phaseTimeoutMs: config.phaseTimeoutMs ?? 10000,
|
|
38
|
+
continueOnError: config.continueOnError ?? false,
|
|
39
|
+
validateDependencies: false,
|
|
40
|
+
enableDebugLogging: config.enableDebugLogging ?? false
|
|
41
|
+
};
|
|
42
|
+
this.allComponents = [];
|
|
43
|
+
this.componentsByLevel = [];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Initialize component tree starting from root component
|
|
48
|
+
*
|
|
49
|
+
* @param rootComponent The root component to start initialization from
|
|
50
|
+
* @param rootName Optional name for the root component (for debugging)
|
|
51
|
+
* @returns Promise that resolves when all components are fully initialized
|
|
52
|
+
*/
|
|
53
|
+
public async initializeFromRoot(rootComponent: LCMComponent): Promise<void> {
|
|
54
|
+
// Phase 1 - Performance post-load setup where each parent will have the change
|
|
55
|
+
// to set dependencies on its children (either to its siblings or to itself).
|
|
56
|
+
await this.performLocalInit(rootComponent);
|
|
57
|
+
|
|
58
|
+
// Phase 2: Dependency Injection - All components receive dependencies
|
|
59
|
+
await this.injectDependencies();
|
|
60
|
+
|
|
61
|
+
await this.activate();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Phase 0: Discover all components via breadth-first traversal
|
|
66
|
+
*/
|
|
67
|
+
private async performLocalInit(rootComponent: LCMComponent): Promise<void> {
|
|
68
|
+
const visited = new Set<LCMComponent>();
|
|
69
|
+
let queue = [rootComponent] as LCMComponent[]
|
|
70
|
+
this.allComponents = [rootComponent]
|
|
71
|
+
|
|
72
|
+
if (this.componentsByLevel.length > 0) {
|
|
73
|
+
throw new Error("Already done")
|
|
74
|
+
}
|
|
75
|
+
for (let level = 0; queue.length > 0; level ++) {
|
|
76
|
+
const newqueue = [] as LCMComponent[];
|
|
77
|
+
const promises = new Array<Promise<LCMComponent[]>>()
|
|
78
|
+
this.componentsByLevel.push(queue)
|
|
79
|
+
for (let i = 0;i < queue.length;i++) {
|
|
80
|
+
const component = queue[i]
|
|
81
|
+
|
|
82
|
+
// Enforce tree structure: each component should have exactly one parent
|
|
83
|
+
if (visited.has(component)) {
|
|
84
|
+
throw new Error(
|
|
85
|
+
`Component hierarchy integrity violation: Component '${component.constructor.name}' ` +
|
|
86
|
+
`discovered as child of multiple parents at level ${level}. ` +
|
|
87
|
+
`Component hierarchies must form a tree structure where each component has exactly one parent.`
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
this.emitEvent(LifecycleEventTypes.LOCAL_INIT_STARTED, component, level)
|
|
92
|
+
const result = component.performLocalInit()
|
|
93
|
+
promises.push(Promise.resolve(result).then((children: LCMComponent[]) => {
|
|
94
|
+
for (const ch of children) {
|
|
95
|
+
this.allComponents.push(ch)
|
|
96
|
+
newqueue.push(ch)
|
|
97
|
+
}
|
|
98
|
+
return children
|
|
99
|
+
}))
|
|
100
|
+
|
|
101
|
+
// now wait for all the promises to finish
|
|
102
|
+
await Promise.all(promises)
|
|
103
|
+
|
|
104
|
+
// TODO - Handle errors
|
|
105
|
+
this.emitEvent(LifecycleEventTypes.LOCAL_INIT_FINISHED, component, level)
|
|
106
|
+
}
|
|
107
|
+
queue = newqueue
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Phase 1: Dependency injection.
|
|
113
|
+
* Here all our components are already discovered so we can call directly
|
|
114
|
+
*/
|
|
115
|
+
protected injectDependencies(): void {
|
|
116
|
+
// This is guaranteed to be in order of levels
|
|
117
|
+
for (const comp of this.allComponents) {
|
|
118
|
+
comp.setupDependencies()
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Phase 2: Activate all components
|
|
124
|
+
*
|
|
125
|
+
* Here all our components are already discovered and their dependencies setup
|
|
126
|
+
* so we can call directly
|
|
127
|
+
*/
|
|
128
|
+
protected async activate(): Promise<void> {
|
|
129
|
+
// This is guaranteed to be in order of levels
|
|
130
|
+
const visited = new Set<LCMComponent>();
|
|
131
|
+
for (let level = 0;level < this.componentsByLevel.length; level++) {
|
|
132
|
+
const promises = new Array<Promise<void>>()
|
|
133
|
+
const levelComps = this.componentsByLevel[level]
|
|
134
|
+
for (const comp of levelComps) {
|
|
135
|
+
if (visited.has(comp)) {
|
|
136
|
+
throw new Error("Comp already visited")
|
|
137
|
+
}
|
|
138
|
+
visited.add(comp)
|
|
139
|
+
this.emitEvent(LifecycleEventTypes.ACTIVATION_STARTED , comp, 0)
|
|
140
|
+
const result = comp.activate()
|
|
141
|
+
promises.push(Promise.resolve(result).then(() => {
|
|
142
|
+
this.emitEvent(LifecycleEventTypes.ACTIVATION_FINISHED, comp, 0)
|
|
143
|
+
}))
|
|
144
|
+
}
|
|
145
|
+
await Promise.all(promises)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Deactivate all components in reverse order. This is usually called when a page quits or a component is
|
|
151
|
+
* deactivated
|
|
152
|
+
*/
|
|
153
|
+
public async deactivateAll(): Promise<void> {
|
|
154
|
+
// destruction should leaf to the root so we are removing things from bottom to top
|
|
155
|
+
for (let level = this.componentsByLevel.length - 1;level >= 0; level--) {
|
|
156
|
+
const promises = new Array<Promise<void>>()
|
|
157
|
+
const levelComps = this.componentsByLevel[level]
|
|
158
|
+
for (const comp of levelComps) {
|
|
159
|
+
this.emitEvent(LifecycleEventTypes.DEACTIVATION_STARTED , comp, level)
|
|
160
|
+
const result = comp.deactivate()
|
|
161
|
+
promises.push(Promise.resolve(result).then(() => {
|
|
162
|
+
this.emitEvent(LifecycleEventTypes.DEACTIVATION_FINISHED, comp, level)
|
|
163
|
+
}))
|
|
164
|
+
}
|
|
165
|
+
await Promise.all(promises)
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Emit lifecycle event to registered callbacks
|
|
171
|
+
*/
|
|
172
|
+
private emitEvent(
|
|
173
|
+
event: LifecycleEventType,
|
|
174
|
+
component: LCMComponent,
|
|
175
|
+
level: number,
|
|
176
|
+
error: Error | null = null,
|
|
177
|
+
): void {
|
|
178
|
+
this.eventBus.emit<ComponentLifecycleEvent>(
|
|
179
|
+
event,
|
|
180
|
+
{ error: error, success: error == null},
|
|
181
|
+
this,
|
|
182
|
+
component
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Log message if debug logging is enabled
|
|
188
|
+
*/
|
|
189
|
+
private log(message: string, ...args: any[]): void {
|
|
190
|
+
if (this.config.enableDebugLogging) {
|
|
191
|
+
console.log(`[LifecycleController] ${message}`, ...args);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Log error message
|
|
197
|
+
*/
|
|
198
|
+
private logError(message: string, error?: any): void {
|
|
199
|
+
console.error(`[LifecycleController] ${message}`, error);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Get current status of all components
|
|
204
|
+
*/
|
|
205
|
+
/*
|
|
206
|
+
public getComponentStatus(): Array<{ name: string; phase: ComponentPhase }> {
|
|
207
|
+
return Array.from(this.allComponents).map(component => ({
|
|
208
|
+
name: this.getComponentName(component),
|
|
209
|
+
phase: this.componentPhases.get(component) || 'created'
|
|
210
|
+
}));
|
|
211
|
+
}
|
|
212
|
+
*/
|
|
213
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { BaseComponent } from './Component';
|
|
2
|
+
import { LCMComponent } from './LCMComponent';
|
|
3
|
+
import { EventBus } from './EventBus';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* MobileBottomDrawer - Reusable bottom drawer component for mobile layouts
|
|
7
|
+
*
|
|
8
|
+
* Features:
|
|
9
|
+
* - Slides up from bottom covering 60-70% of viewport
|
|
10
|
+
* - Backdrop overlay that dims the content behind
|
|
11
|
+
* - Auto-closes when backdrop is tapped
|
|
12
|
+
* - Swipe down to close gesture
|
|
13
|
+
* - Smooth slide-up/down animations
|
|
14
|
+
* - Holds any panel content
|
|
15
|
+
*/
|
|
16
|
+
export class MobileBottomDrawer extends BaseComponent implements LCMComponent {
|
|
17
|
+
private backdropElement: HTMLElement;
|
|
18
|
+
private drawerElement: HTMLElement;
|
|
19
|
+
private contentElement: HTMLElement;
|
|
20
|
+
private closeButton: HTMLElement | null;
|
|
21
|
+
private isOpen: boolean = false;
|
|
22
|
+
private onCloseCallback?: () => void;
|
|
23
|
+
|
|
24
|
+
// Swipe gesture tracking
|
|
25
|
+
private touchStartY: number = 0;
|
|
26
|
+
private touchCurrentY: number = 0;
|
|
27
|
+
private isDragging: boolean = false;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Create a MobileBottomDrawer
|
|
31
|
+
* @param rootElement - The root container element for the drawer
|
|
32
|
+
* @param eventBus - Event bus for component communication
|
|
33
|
+
* @param debugMode - Enable debug logging
|
|
34
|
+
*/
|
|
35
|
+
constructor(rootElement: HTMLElement, eventBus: EventBus, debugMode: boolean = false) {
|
|
36
|
+
super('mobile-bottom-drawer', rootElement, eventBus, debugMode);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Phase 1: Initialize DOM and discover child components
|
|
41
|
+
*/
|
|
42
|
+
async performLocalInit(): Promise<LCMComponent[]> {
|
|
43
|
+
// Find drawer elements
|
|
44
|
+
this.backdropElement = this.rootElement.querySelector('.drawer-backdrop') as HTMLElement;
|
|
45
|
+
this.drawerElement = this.rootElement.querySelector('.drawer-container') as HTMLElement;
|
|
46
|
+
this.contentElement = this.rootElement.querySelector('.drawer-content') as HTMLElement;
|
|
47
|
+
this.closeButton = this.rootElement.querySelector('.drawer-close-btn') as HTMLElement;
|
|
48
|
+
|
|
49
|
+
if (!this.backdropElement || !this.drawerElement || !this.contentElement) {
|
|
50
|
+
throw new Error('MobileBottomDrawer: Required drawer elements not found');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Bind event listeners
|
|
54
|
+
this.bindEvents();
|
|
55
|
+
|
|
56
|
+
return [];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Bind event listeners for drawer interactions
|
|
61
|
+
*/
|
|
62
|
+
private bindEvents(): void {
|
|
63
|
+
// Close drawer on backdrop click
|
|
64
|
+
this.backdropElement.addEventListener('click', (e) => {
|
|
65
|
+
if (e.target === this.backdropElement) {
|
|
66
|
+
this.close();
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// Close drawer on close button click
|
|
71
|
+
if (this.closeButton) {
|
|
72
|
+
this.closeButton.addEventListener('click', () => {
|
|
73
|
+
this.close();
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Close drawer on Escape key
|
|
78
|
+
document.addEventListener('keydown', (e) => {
|
|
79
|
+
if (e.key === 'Escape' && this.isOpen) {
|
|
80
|
+
this.close();
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// Swipe-to-close gesture on drawer element
|
|
85
|
+
this.drawerElement.addEventListener('touchstart', (e) => this.handleTouchStart(e), { passive: true });
|
|
86
|
+
this.drawerElement.addEventListener('touchmove', (e) => this.handleTouchMove(e), { passive: false });
|
|
87
|
+
this.drawerElement.addEventListener('touchend', (e) => this.handleTouchEnd(e), { passive: true });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Handle touch start for swipe gesture
|
|
92
|
+
*/
|
|
93
|
+
private handleTouchStart(e: TouchEvent): void {
|
|
94
|
+
// Only track if touching the drawer itself (not scrollable content)
|
|
95
|
+
const contentScrollTop = this.contentElement.scrollTop;
|
|
96
|
+
|
|
97
|
+
// If content is scrolled down, let normal scrolling happen
|
|
98
|
+
if (contentScrollTop > 0) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
this.touchStartY = e.touches[0].clientY;
|
|
103
|
+
this.touchCurrentY = this.touchStartY;
|
|
104
|
+
this.isDragging = true;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Handle touch move for swipe gesture
|
|
109
|
+
*/
|
|
110
|
+
private handleTouchMove(e: TouchEvent): void {
|
|
111
|
+
if (!this.isDragging) return;
|
|
112
|
+
|
|
113
|
+
this.touchCurrentY = e.touches[0].clientY;
|
|
114
|
+
const deltaY = this.touchCurrentY - this.touchStartY;
|
|
115
|
+
|
|
116
|
+
// Only allow downward swipes
|
|
117
|
+
if (deltaY > 0) {
|
|
118
|
+
// Prevent default scrolling when swiping down
|
|
119
|
+
e.preventDefault();
|
|
120
|
+
|
|
121
|
+
// Apply drag transform with resistance
|
|
122
|
+
const dragAmount = Math.min(deltaY, 300); // Cap at 300px
|
|
123
|
+
this.drawerElement.style.transform = `translateY(${dragAmount}px)`;
|
|
124
|
+
|
|
125
|
+
// Reduce backdrop opacity based on drag amount
|
|
126
|
+
const opacity = Math.max(0, 0.5 - (dragAmount / 600));
|
|
127
|
+
this.backdropElement.style.background = `rgba(0, 0, 0, ${opacity})`;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Handle touch end for swipe gesture
|
|
133
|
+
*/
|
|
134
|
+
private handleTouchEnd(e: TouchEvent): void {
|
|
135
|
+
if (!this.isDragging) return;
|
|
136
|
+
|
|
137
|
+
const deltaY = this.touchCurrentY - this.touchStartY;
|
|
138
|
+
const velocity = deltaY / 300; // Rough velocity calculation
|
|
139
|
+
|
|
140
|
+
// Close if swiped down more than 100px or with high velocity
|
|
141
|
+
if (deltaY > 100 || velocity > 0.3) {
|
|
142
|
+
this.close();
|
|
143
|
+
} else {
|
|
144
|
+
// Snap back to open position
|
|
145
|
+
this.drawerElement.style.transform = '';
|
|
146
|
+
this.backdropElement.style.background = '';
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
this.isDragging = false;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Open the drawer with slide-up animation
|
|
154
|
+
*/
|
|
155
|
+
public open(): void {
|
|
156
|
+
if (this.isOpen) return;
|
|
157
|
+
|
|
158
|
+
this.isOpen = true;
|
|
159
|
+
|
|
160
|
+
// Reset any drag transform
|
|
161
|
+
this.drawerElement.style.transform = '';
|
|
162
|
+
this.backdropElement.style.background = '';
|
|
163
|
+
|
|
164
|
+
// Add open class - CSS handles all animations
|
|
165
|
+
this.rootElement.classList.add('open');
|
|
166
|
+
|
|
167
|
+
// Emit event
|
|
168
|
+
this.eventBus.emit('drawer-opened', { drawerId: this.componentId }, null, this);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Close the drawer with slide-down animation
|
|
173
|
+
*/
|
|
174
|
+
public close(): void {
|
|
175
|
+
if (!this.isOpen) return;
|
|
176
|
+
|
|
177
|
+
this.isOpen = false;
|
|
178
|
+
|
|
179
|
+
// Reset any drag transform
|
|
180
|
+
this.drawerElement.style.transform = '';
|
|
181
|
+
this.backdropElement.style.background = '';
|
|
182
|
+
|
|
183
|
+
// Remove open class - CSS handles animation
|
|
184
|
+
this.rootElement.classList.remove('open');
|
|
185
|
+
|
|
186
|
+
// Emit event
|
|
187
|
+
this.eventBus.emit('drawer-closed', { drawerId: this.componentId }, null, this);
|
|
188
|
+
|
|
189
|
+
// Call close callback if provided
|
|
190
|
+
if (this.onCloseCallback) {
|
|
191
|
+
this.onCloseCallback();
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Toggle drawer open/closed
|
|
197
|
+
*/
|
|
198
|
+
public toggle(): void {
|
|
199
|
+
if (this.isOpen) {
|
|
200
|
+
this.close();
|
|
201
|
+
} else {
|
|
202
|
+
this.open();
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Check if drawer is currently open
|
|
208
|
+
*/
|
|
209
|
+
public getIsOpen(): boolean {
|
|
210
|
+
return this.isOpen;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Set the content element for the drawer
|
|
215
|
+
* @param element - The element to insert into the drawer content area
|
|
216
|
+
*/
|
|
217
|
+
public setContent(element: HTMLElement): void {
|
|
218
|
+
this.contentElement.innerHTML = '';
|
|
219
|
+
this.contentElement.appendChild(element);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Set a callback to be called when drawer closes
|
|
224
|
+
* @param callback - Function to call on close
|
|
225
|
+
*/
|
|
226
|
+
public setOnClose(callback: () => void): void {
|
|
227
|
+
this.onCloseCallback = callback;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Get the content container element
|
|
232
|
+
*/
|
|
233
|
+
public getContentContainer(): HTMLElement {
|
|
234
|
+
return this.contentElement;
|
|
235
|
+
}
|
|
236
|
+
}
|
package/src/Modal.ts
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { TemplateLoader } from './TemplateLoader';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Modal manager for the application
|
|
5
|
+
* Handles showing and hiding modals with different content
|
|
6
|
+
*/
|
|
7
|
+
export class Modal {
|
|
8
|
+
private static instance: Modal | null = null;
|
|
9
|
+
|
|
10
|
+
// Modal DOM elements
|
|
11
|
+
private modalContainer: HTMLDivElement
|
|
12
|
+
private modalBackdrop: HTMLElement | null;
|
|
13
|
+
private modalPanel: HTMLElement | null;
|
|
14
|
+
private modalContent: HTMLElement | null;
|
|
15
|
+
private closeButton: HTMLElement | null;
|
|
16
|
+
|
|
17
|
+
private templateLoader: TemplateLoader;
|
|
18
|
+
|
|
19
|
+
// Current modal data - including optional callbacks
|
|
20
|
+
private currentTemplateId: string | null = null;
|
|
21
|
+
private currentData: any = null;
|
|
22
|
+
private onSubmitCallback: ((modalData: any) => void) | null = null; // Store the callback
|
|
23
|
+
private onApplyCallback: ((modalData: any) => void) | null = null; // Store the Apply callback
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Private constructor for singleton pattern
|
|
28
|
+
*/
|
|
29
|
+
private constructor() {
|
|
30
|
+
// Get modal elements
|
|
31
|
+
this.modalContainer = document.getElementById('modal-container') as HTMLDivElement;
|
|
32
|
+
this.modalBackdrop = document.getElementById('modal-backdrop');
|
|
33
|
+
this.modalPanel = document.getElementById('modal-panel');
|
|
34
|
+
this.modalContent = document.getElementById('modal-content');
|
|
35
|
+
this.closeButton = document.getElementById('modal-close');
|
|
36
|
+
|
|
37
|
+
this.templateLoader = new TemplateLoader();
|
|
38
|
+
|
|
39
|
+
this.bindEvents();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Get the Modal instance (singleton)
|
|
44
|
+
*/
|
|
45
|
+
public static getInstance(): Modal {
|
|
46
|
+
if (!Modal.instance) {
|
|
47
|
+
Modal.instance = new Modal();
|
|
48
|
+
}
|
|
49
|
+
return Modal.instance;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Bind event listeners for modal interactions
|
|
54
|
+
*/
|
|
55
|
+
private bindEvents(): void {
|
|
56
|
+
// Close button click
|
|
57
|
+
if (this.closeButton) {
|
|
58
|
+
this.closeButton.addEventListener('click', () => this.hide());
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Click on backdrop to close
|
|
62
|
+
if (this.modalBackdrop) {
|
|
63
|
+
this.modalBackdrop.addEventListener('click', (e) => {
|
|
64
|
+
// Only close if clicking directly on the backdrop
|
|
65
|
+
if (e.target === this.modalBackdrop) {
|
|
66
|
+
this.hide();
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Listen for Escape key
|
|
72
|
+
document.addEventListener('keydown', (e) => {
|
|
73
|
+
if (e.key === 'Escape' && this.isVisible()) {
|
|
74
|
+
this.hide();
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// --- Event Delegation for Modal Actions ---
|
|
79
|
+
if (this.modalPanel) { // Listen on a persistent parent element
|
|
80
|
+
this.modalPanel.addEventListener('click', (e: MouseEvent) => {
|
|
81
|
+
const target = e.target as HTMLElement;
|
|
82
|
+
|
|
83
|
+
// Handle generic close/cancel buttons
|
|
84
|
+
const closeButton = target.closest('button[id$="-cancel"], button[id$="-close"]');
|
|
85
|
+
if (closeButton) {
|
|
86
|
+
this.hide();
|
|
87
|
+
return; // Stop further processing
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Handle specific actions like submit
|
|
91
|
+
const actionButton = target.closest('button[data-modal-action]');
|
|
92
|
+
if (actionButton) {
|
|
93
|
+
const action = actionButton.getAttribute('data-modal-action');
|
|
94
|
+
|
|
95
|
+
if (action === 'submit' && this.onSubmitCallback) {
|
|
96
|
+
this.onSubmitCallback(this.currentData); // Call the stored callback
|
|
97
|
+
} else if (action === 'apply' && this.onApplyCallback) {
|
|
98
|
+
this.onApplyCallback(this.currentData); // Call the Apply callback
|
|
99
|
+
this.hide(); // Typically hide after applying
|
|
100
|
+
}
|
|
101
|
+
// Add more actions (e.g., 'apply', 'revise') later if needed
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Check if the modal is currently visible
|
|
109
|
+
*/
|
|
110
|
+
public isVisible(): boolean {
|
|
111
|
+
return this.modalContainer ? !this.modalContainer.classList.contains('hidden') : false;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Show a modal with content from the specified template ID.
|
|
116
|
+
* Uses TemplateLoader to get the content element.
|
|
117
|
+
* @param templateId ID used in `data-template-id` attribute in TemplateRegistry.html
|
|
118
|
+
* @param data Optional data to pass to the modal. Can include callbacks like `onSubmit`.
|
|
119
|
+
* @returns The root HTMLElement of the loaded content, or null if failed.
|
|
120
|
+
*/
|
|
121
|
+
public show(templateId: string, data: any = null): HTMLElement | null {
|
|
122
|
+
if (!this.modalContainer || !this.modalContent) {
|
|
123
|
+
console.error("Modal container or content area not found.");
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Use TemplateLoader to get the content element
|
|
128
|
+
// Load content directly into the modal content area
|
|
129
|
+
const success = this.templateLoader.loadInto(templateId, this.modalContent);
|
|
130
|
+
if (!success) {
|
|
131
|
+
// Error message is already placed into modalContent by loadInto on failure
|
|
132
|
+
// Ensure modal is still visible
|
|
133
|
+
this.modalContainer.classList.remove('hidden');
|
|
134
|
+
setTimeout(() => this.modalContainer.classList.add('modal-active'), 10);
|
|
135
|
+
return null; // Indicate failure
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Store current modal info
|
|
139
|
+
this.currentTemplateId = templateId;
|
|
140
|
+
this.currentData = data || {}; // Ensure data is an object
|
|
141
|
+
this.onSubmitCallback = data?.onSubmit || null; // Store the submit callback
|
|
142
|
+
this.onApplyCallback = data?.onApply || null; // Store the apply callback
|
|
143
|
+
|
|
144
|
+
// Set data attributes for non-function data
|
|
145
|
+
if (data) {
|
|
146
|
+
Object.entries(data).forEach(([key, value]) => {
|
|
147
|
+
if (key !== 'onSubmit' && (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean')) {
|
|
148
|
+
if (this.modalContent) this.modalContent.dataset[key] = String(value);
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Show modal container
|
|
154
|
+
this.modalContainer.classList.remove('hidden');
|
|
155
|
+
|
|
156
|
+
// Trigger animations if needed (add active class after a tick)
|
|
157
|
+
setTimeout(() => {
|
|
158
|
+
this.modalContainer.classList.add('modal-active');
|
|
159
|
+
}, 10); // Small delay ensures transition applies correctly
|
|
160
|
+
|
|
161
|
+
const firstElement = this.modalContent//?.firstElementChild as HTMLElement | null;
|
|
162
|
+
return firstElement;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Hide the modal
|
|
167
|
+
*/
|
|
168
|
+
public hide(): Promise<void> {
|
|
169
|
+
return new Promise((resolve) => {
|
|
170
|
+
if (!this.modalContainer) return;
|
|
171
|
+
|
|
172
|
+
// Remove active class first (for animations)
|
|
173
|
+
this.modalContainer.classList.remove('modal-active');
|
|
174
|
+
|
|
175
|
+
// Hide after a short delay
|
|
176
|
+
setTimeout(() => {
|
|
177
|
+
this.modalContainer.classList.add('hidden');
|
|
178
|
+
|
|
179
|
+
// Clear current modal info
|
|
180
|
+
this.currentTemplateId = null;
|
|
181
|
+
this.currentData = null;
|
|
182
|
+
this.onSubmitCallback = null; // Clear callback
|
|
183
|
+
this.onApplyCallback = null; // Clear apply callback
|
|
184
|
+
if(this.modalContent) this.modalContent.innerHTML = ''; // Clear content
|
|
185
|
+
resolve();
|
|
186
|
+
}, 200); // Match typical transition duration
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Get the current modal content element
|
|
192
|
+
*/
|
|
193
|
+
public getContentElement(): HTMLElement | null {
|
|
194
|
+
return this.modalContent;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Get the current template ID
|
|
199
|
+
*/
|
|
200
|
+
public getCurrentTemplate(): string | null {
|
|
201
|
+
return this.currentTemplateId;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Get the current modal data
|
|
206
|
+
*/
|
|
207
|
+
public getCurrentData(): any {
|
|
208
|
+
return this.currentData;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Update modal data (excluding callbacks for now)
|
|
213
|
+
*/
|
|
214
|
+
public updateData(newData: any): void {
|
|
215
|
+
this.currentData = { ...this.currentData, ...newData };
|
|
216
|
+
|
|
217
|
+
// Update data attributes for non-function data
|
|
218
|
+
if (this.modalContent && newData) {
|
|
219
|
+
Object.entries(newData).forEach(([key, value]) => { // Exclude callbacks when setting data attributes
|
|
220
|
+
if (key !== 'onSubmit' && (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean')) {
|
|
221
|
+
if (this.modalContent) this.modalContent.dataset[key] = String(value);
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Initialize the modal component
|
|
229
|
+
*/
|
|
230
|
+
public static init(): Modal {
|
|
231
|
+
return Modal.getInstance();
|
|
232
|
+
}
|
|
233
|
+
}
|