/** * Enhanced Popup System for UP Connector * Provides unified interface for both popup windows and modal dialogs */ export interface PopupRequest { mode: 'popup' | 'modal' // popup = window.open, modal = iframe in modal url?: string // If present = external, if not = local content size?: { width: number; height: number } position?: 'center' | 'top-right' allowedOrigins?: string[] // Security for message passing reuseExisting?: boolean // Whether to reuse existing popups (default: true) } export interface PopupInstance { // Lifecycle Management show(): Promise // Show the popup/modal - async for consistency hide(): void // Hide but preserve state close(): void // Actually destroy // Message Passing postMessage(data: any): void // Send to popup/iframe onMessage(handler: (data: any) => void): void // Receive from popup/iframe removeMessageHandler(handler: (data: any) => void): void // Remove handler // State Queries isVisible: boolean // Whether currently visible isClosed: boolean // Whether destroyed contentWindow?: Window // For advanced use // Events on( event: 'show' | 'hide' | 'close' | 'message', handler: (...args: any[]) => void ): void off( event: 'show' | 'hide' | 'close' | 'message', handler: (...args: any[]) => void ): void } export interface UIDelegate { createPopup(request: PopupRequest): PopupInstance showPopup(request: PopupRequest): Promise closeAll(): void } // Internal event emitter for popup instances class PopupEventEmitter { private handlers = new Map void>>() on(event: string, handler: (...args: any[]) => void): void { if (!this.handlers.has(event)) { this.handlers.set(event, new Set()) } this.handlers.get(event)?.add(handler) } off(event: string, handler: (...args: any[]) => void): void { this.handlers.get(event)?.delete(handler) } emit(event: string, ...args: any[]): void { this.handlers.get(event)?.forEach((handler) => { try { handler(...args) } catch (error) { console.error('Error in popup event handler:', error) } }) } clear(): void { this.handlers.clear() } } /** * Base class for popup instances */ abstract class BasePopupInstance implements PopupInstance { protected events = new PopupEventEmitter() protected messageHandlers = new Set<(data: any) => void>() protected _isClosed = false protected _isVisible = false constructor(protected request: PopupRequest) {} abstract show(): Promise abstract hide(): void abstract close(): void abstract get contentWindow(): Window | undefined get isVisible(): boolean { return this._isVisible && !this._isClosed } get isClosed(): boolean { return this._isClosed } postMessage(data: any): void { if (!this._isClosed && this.contentWindow) { try { this.contentWindow.postMessage(data, '*') } catch (error) { console.error('Failed to post message to popup:', error) } } } onMessage(handler: (data: any) => void): void { this.messageHandlers.add(handler) } removeMessageHandler(handler: (data: any) => void): void { this.messageHandlers.delete(handler) } protected handleMessage(data: any): void { this.messageHandlers.forEach((handler) => { try { handler(data) } catch (error) { console.error('Error in popup message handler:', error) } }) this.events.emit('message', data) } on( event: 'show' | 'hide' | 'close' | 'message', handler: (...args: any[]) => void ): void { this.events.on(event, handler) } off( event: 'show' | 'hide' | 'close' | 'message', handler: (...args: any[]) => void ): void { this.events.off(event, handler) } protected cleanup(): void { this.messageHandlers.clear() this.events.clear() } } /** * Modal popup implementation (iframe in modal dialog) */ export class ModalPopupInstance extends BasePopupInstance { private modalElement?: HTMLDivElement private iframeElement?: HTMLIFrameElement private messageListener?: (event: MessageEvent) => void async show(): Promise { if (this._isClosed) { throw new Error('Popup is closed') } if (!this.modalElement) { await this.createElement() } return new Promise((resolve) => { if (!this.modalElement) { resolve() return } // Add to DOM if not already present if (!this.modalElement.parentNode) { document.body.appendChild(this.modalElement) } this._isVisible = true // Force reflow before adding visible class this.modalElement.offsetHeight this.modalElement.classList.add('visible') this.events.emit('show') // Wait for animation setTimeout(() => resolve(), 50) }) } hide(): void { if (!this._isVisible || this._isClosed || !this.modalElement) return this._isVisible = false this.modalElement.classList.remove('visible') this.events.emit('hide') // Remove from DOM after animation setTimeout(() => { if (this.modalElement?.parentNode) { this.modalElement.parentNode.removeChild(this.modalElement) } }, 300) } close(): void { if (this._isClosed) return this._isClosed = true this._isVisible = false if (this.modalElement) { this.modalElement.classList.remove('visible') setTimeout(() => { if (this.modalElement?.parentNode) { this.modalElement.parentNode.removeChild(this.modalElement) } }, 300) } if (this.messageListener) { window.removeEventListener('message', this.messageListener) } this.events.emit('close') this.cleanup() } get contentWindow(): Window | undefined { return this.iframeElement?.contentWindow || undefined } private async createElement(): Promise { if (typeof document === 'undefined') return await this.createStyles() // Create modal structure this.modalElement = document.createElement('div') this.modalElement.className = 'up-popup-modal' const { width = 480, height = 640 } = this.request.size || {} this.modalElement.innerHTML = `
` // Get references const closeButton = this.modalElement.querySelector( '.up-popup-close' ) as HTMLButtonElement this.iframeElement = this.modalElement.querySelector( 'iframe' ) as HTMLIFrameElement // Event listeners closeButton.addEventListener('click', () => this.hide()) this.modalElement.addEventListener('click', (e) => { if (e.target === this.modalElement) { this.hide() } }) // Message handling this.messageListener = (event: MessageEvent) => { // Only handle messages from our iframe if (event.source === this.iframeElement?.contentWindow) { this.handleMessage(event.data) } } window.addEventListener('message', this.messageListener) } private async createStyles(): Promise { if (typeof document === 'undefined') return if (document.querySelector('#up-popup-modal-styles')) return const style = document.createElement('style') style.id = 'up-popup-modal-styles' style.textContent = ` .up-popup-modal { position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: rgba(0, 0, 0, 0.5); backdrop-filter: blur(8px); z-index: 10000; display: flex; align-items: center; justify-content: center; opacity: 0; visibility: hidden; transition: all 300ms cubic-bezier(0.25, 0.46, 0.45, 0.94); padding: 16px; } .up-popup-modal.visible { opacity: 1; visibility: visible; } .up-popup-modal-content { background: white; border-radius: 12px; overflow: hidden; position: relative; box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04); transform: scale(0.95) translateY(20px); transition: all 300ms cubic-bezier(0.25, 0.46, 0.45, 0.94); } .up-popup-modal.visible .up-popup-modal-content { transform: scale(1) translateY(0); } .up-popup-close { position: absolute; top: 12px; right: 12px; z-index: 10; background: rgba(255, 255, 255, 0.9); border: none; border-radius: 50%; width: 32px; height: 32px; cursor: pointer; display: flex; align-items: center; justify-content: center; color: #6b7280; transition: all 0.2s ease; } .up-popup-close:hover { background: rgba(255, 255, 255, 1); color: #374151; } .up-popup-modal iframe { width: 100%; height: 100%; border: none; border-radius: 12px; } @media (max-width: 640px) { .up-popup-modal { padding: 0; align-items: flex-end; } .up-popup-modal-content { width: 100% !important; height: 85vh !important; border-radius: 12px 12px 0 0; transform: translateY(100%); } .up-popup-modal.visible .up-popup-modal-content { transform: translateY(0); } .up-popup-modal iframe { border-radius: 12px 12px 0 0; } } @media (max-width: 480px) { .up-popup-modal { align-items: stretch; } .up-popup-modal-content { height: 100vh !important; border-radius: 0; } .up-popup-modal iframe { border-radius: 0; } } ` document.head.appendChild(style) } } /** * Window popup implementation (actual popup window) */ export class WindowPopupInstance extends BasePopupInstance { private popupWindow: Window | null = null private messageListener?: (event: MessageEvent) => void async show(): Promise { if (this._isClosed) { throw new Error('Popup is closed') } if (!this.popupWindow || this.popupWindow.closed) { await this.createPopupWindow() } if (this.popupWindow && !this.popupWindow.closed) { this.popupWindow.focus() this._isVisible = true this.events.emit('show') } } hide(): void { // For popup windows, hide typically means minimize or move to background // Since we can't directly minimize, we just blur it if (this.popupWindow && !this.popupWindow.closed) { this.popupWindow.blur() } this._isVisible = false this.events.emit('hide') } close(): void { if (this._isClosed) return this._isClosed = true this._isVisible = false if (this.popupWindow && !this.popupWindow.closed) { this.popupWindow.close() } this.popupWindow = null if (this.messageListener) { window.removeEventListener('message', this.messageListener) } this.events.emit('close') this.cleanup() } get contentWindow(): Window | undefined { return this.popupWindow && !this.popupWindow.closed ? this.popupWindow : undefined } get isVisible(): boolean { return !this._isClosed && !!(this.popupWindow && !this.popupWindow.closed) } private async createPopupWindow(): Promise { if (!this.request.url) { throw new Error('URL required for popup windows') } const { width = 400, height = 600 } = this.request.size || {} const position = this.request.position || 'center' const features = [ `width=${width}`, `height=${height}`, ...this.getPositionFeatures(position, width, height), 'scrollbars=yes', 'resizable=yes', 'status=no', 'location=no', 'toolbar=no', 'menubar=no', ].join(',') this.popupWindow = window.open(this.request.url, '_blank', features) if (!this.popupWindow) { throw new Error('Popup blocked or failed to open') } // Setup message listener this.messageListener = (event: MessageEvent) => { if (event.source === this.popupWindow) { this.handleMessage(event.data) } } window.addEventListener('message', this.messageListener) // Monitor popup close const checkClosed = () => { if (this.popupWindow?.closed && !this._isClosed) { this.close() } else if (!this._isClosed) { setTimeout(checkClosed, 1000) } } setTimeout(checkClosed, 1000) } private getPositionFeatures( position: string, width: number, height: number ): string[] { const screenLeft = window.screenLeft !== undefined ? window.screenLeft : window.screenX const screenTop = window.screenTop !== undefined ? window.screenTop : window.screenY const windowWidth = window.innerWidth || document.documentElement.clientWidth || screen.width const windowHeight = window.innerHeight || document.documentElement.clientHeight || screen.height let left: number let top: number switch (position) { case 'top-right': left = screenLeft + windowWidth - width - 20 top = screenTop + 20 break default: left = screenLeft + (windowWidth - width) / 2 top = screenTop + (windowHeight - height) / 2 break } return [`left=${Math.max(0, left)}`, `top=${Math.max(0, top)}`] } }