UNPKG

@tinytapanalytics/sdk

Version:

Behavioral psychology platform that detects visitor frustration, predicts abandonment, and helps you save at-risk conversions in real-time

896 lines (765 loc) 21.1 kB
/** * Shadow DOM Manager for TinyTapAnalytics SDK * Provides complete CSS isolation for UI elements */ interface ModalConfig { title?: string; content: string; persistent?: boolean; className?: string; width?: string; height?: string; allowHTML?: boolean; // Allow HTML in content (default: false) } interface NotificationConfig { message: string; type?: 'info' | 'success' | 'warning' | 'error'; duration?: number; className?: string; } export class ShadowDOMManager { private shadowHost: HTMLElement; private shadowRoot: ShadowRoot; private styleSheet: CSSStyleSheet | null = null; private components: Map<string, HTMLElement> = new Map(); private componentCounter = 0; constructor() { this.shadowHost = this.createShadowHost(); this.shadowRoot = this.shadowHost.attachShadow({ mode: 'closed' }); this.initializeStyles(); } /** * Create the shadow host element */ private createShadowHost(): HTMLElement { const host = document.createElement('div'); host.id = 'tinytapanalytics-shadow-host'; // Position off-screen initially host.style.cssText = ` position: fixed; top: -9999px; left: -9999px; width: 0; height: 0; pointer-events: none; z-index: 2147483647; `; document.body.appendChild(host); return host; } /** * Initialize CSS styles for the shadow DOM */ private initializeStyles(): void { // Try to use Constructable Stylesheets (modern browsers) if ('adoptedStyleSheets' in this.shadowRoot) { try { this.styleSheet = new CSSStyleSheet(); this.styleSheet.replaceSync(this.getBaseStyles()); (this.shadowRoot as any).adoptedStyleSheets = [this.styleSheet]; return; } catch (error) { // Fall back to style element } } // Fallback for older browsers const style = document.createElement('style'); style.textContent = this.getBaseStyles(); this.shadowRoot.appendChild(style); } /** * Get base CSS styles for shadow DOM components */ private getBaseStyles(): string { return ` /* Reset and base styles */ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } /* Base typography */ .ciq-component { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; font-size: 14px; line-height: 1.5; color: #374151; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } /* Modal styles */ .ciq-modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0, 0, 0, 0.5); display: flex; align-items: center; justify-content: center; z-index: 10000; opacity: 0; transition: opacity 0.2s ease-in-out; pointer-events: auto; } .ciq-modal-overlay.ciq-show { opacity: 1; } .ciq-modal { background: white; border-radius: 8px; box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04); max-width: 500px; max-height: 90vh; width: 90%; overflow: hidden; transform: scale(0.95); transition: transform 0.2s ease-in-out; } .ciq-modal-overlay.ciq-show .ciq-modal { transform: scale(1); } .ciq-modal-header { padding: 20px 24px 0; border-bottom: 1px solid #e5e7eb; } .ciq-modal-title { font-size: 18px; font-weight: 600; color: #111827; margin-bottom: 8px; } .ciq-modal-body { padding: 20px 24px; max-height: 60vh; overflow-y: auto; } .ciq-modal-footer { padding: 20px 24px; border-top: 1px solid #e5e7eb; display: flex; gap: 12px; justify-content: flex-end; } /* Consent modal specific styles */ .consent-modal .ciq-modal { max-width: 600px; } .ciq-consent-content h3 { font-size: 20px; font-weight: 600; color: #111827; margin-bottom: 16px; } .ciq-consent-content p { color: #6b7280; margin-bottom: 24px; line-height: 1.6; } .ciq-consent-options { space-y: 16px; margin-bottom: 24px; } .ciq-consent-option { margin-bottom: 16px; } .ciq-checkbox-label { display: flex; align-items: flex-start; gap: 12px; cursor: pointer; padding: 16px; border: 1px solid #e5e7eb; border-radius: 6px; transition: border-color 0.2s, background-color 0.2s; } .ciq-checkbox-label:hover { border-color: #d1d5db; background-color: #f9fafb; } .ciq-checkbox-label input[type="checkbox"] { position: absolute; opacity: 0; cursor: pointer; } .ciq-checkmark { position: relative; height: 20px; width: 20px; background-color: #fff; border: 2px solid #d1d5db; border-radius: 4px; flex-shrink: 0; transition: all 0.2s; } .ciq-checkbox-label input:checked ~ .ciq-checkmark { background-color: #3b82f6; border-color: #3b82f6; } .ciq-checkbox-label input:disabled ~ .ciq-checkmark { background-color: #f3f4f6; border-color: #e5e7eb; } .ciq-checkmark:after { content: ""; position: absolute; display: none; left: 6px; top: 2px; width: 6px; height: 10px; border: solid white; border-width: 0 2px 2px 0; transform: rotate(45deg); } .ciq-checkbox-label input:checked ~ .ciq-checkmark:after { display: block; } .ciq-option-details { flex: 1; } .ciq-option-details strong { display: block; font-weight: 600; color: #111827; margin-bottom: 4px; } .ciq-option-details p { color: #6b7280; font-size: 13px; margin: 0; } .ciq-consent-actions { display: flex; gap: 12px; flex-wrap: wrap; justify-content: flex-end; margin-bottom: 16px; } .ciq-consent-footer p { font-size: 12px; color: #9ca3af; margin: 0; text-align: center; } /* Button styles */ .ciq-btn { display: inline-flex; align-items: center; justify-content: center; padding: 8px 16px; border: 1px solid transparent; border-radius: 6px; font-size: 14px; font-weight: 500; text-decoration: none; cursor: pointer; transition: all 0.2s; min-width: 80px; } .ciq-btn-primary { background-color: #3b82f6; color: white; border-color: #3b82f6; } .ciq-btn-primary:hover { background-color: #2563eb; border-color: #2563eb; } .ciq-btn-secondary { background-color: #f3f4f6; color: #374151; border-color: #d1d5db; } .ciq-btn-secondary:hover { background-color: #e5e7eb; border-color: #9ca3af; } /* Notification styles */ .ciq-notification { position: fixed; top: 20px; right: 20px; background: white; border-radius: 8px; box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); padding: 16px; max-width: 400px; border-left: 4px solid #3b82f6; transform: translateX(100%); transition: transform 0.3s ease-in-out; z-index: 10001; pointer-events: auto; } .ciq-notification.ciq-show { transform: translateX(0); } .ciq-notification.ciq-success { border-left-color: #10b981; } .ciq-notification.ciq-warning { border-left-color: #f59e0b; } .ciq-notification.ciq-error { border-left-color: #ef4444; } .ciq-notification-message { font-size: 14px; color: #374151; line-height: 1.5; } /* Loading spinner */ .ciq-spinner { width: 20px; height: 20px; border: 2px solid #f3f4f6; border-top: 2px solid #3b82f6; border-radius: 50%; animation: ciq-spin 1s linear infinite; } @keyframes ciq-spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } /* Responsive design */ @media (max-width: 640px) { .ciq-modal { width: 95%; margin: 20px; } .ciq-modal-body { max-height: 50vh; } .ciq-consent-actions { flex-direction: column; } .ciq-btn { width: 100%; } .ciq-notification { left: 20px; right: 20px; max-width: none; } } /* Dark mode support */ @media (prefers-color-scheme: dark) { .ciq-modal { background: #1f2937; color: #f9fafb; } .ciq-modal-header { border-bottom-color: #374151; } .ciq-modal-footer { border-top-color: #374151; } .ciq-modal-title { color: #f9fafb; } .ciq-checkbox-label { border-color: #374151; background-color: #1f2937; } .ciq-checkbox-label:hover { border-color: #4b5563; background-color: #111827; } .ciq-checkmark { background-color: #374151; border-color: #4b5563; } .ciq-option-details strong { color: #f9fafb; } .ciq-btn-secondary { background-color: #374151; color: #f9fafb; border-color: #4b5563; } .ciq-btn-secondary:hover { background-color: #4b5563; border-color: #6b7280; } .ciq-notification { background: #1f2937; color: #f9fafb; } } /* High contrast mode support */ @media (prefers-contrast: high) { .ciq-modal { border: 2px solid currentColor; } .ciq-btn { border-width: 2px; } .ciq-checkmark { border-width: 3px; } } /* Reduced motion support */ @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; } } /* Consent Banner Styles */ .ciq-consent-banner { position: fixed; bottom: 0; left: 0; right: 0; background: white; border-top: 1px solid #e5e7eb; box-shadow: 0 -4px 6px -1px rgba(0, 0, 0, 0.1), 0 -2px 4px -1px rgba(0, 0, 0, 0.06); padding: 16px 24px; z-index: 10000; transform: translateY(100%); transition: transform 0.3s ease-in-out; pointer-events: auto; } .ciq-consent-banner.ciq-show { transform: translateY(0); } .ciq-consent-banner-content { max-width: 1200px; margin: 0 auto; display: flex; align-items: center; gap: 24px; } .ciq-consent-banner-text { flex: 1; min-width: 0; } .ciq-consent-banner-text h4 { font-size: 15px; font-weight: 600; color: #111827; margin: 0 0 4px 0; } .ciq-consent-banner-text p { font-size: 13px; color: #6b7280; margin: 0; line-height: 1.4; } .ciq-consent-banner-actions { display: flex; gap: 12px; align-items: center; flex-shrink: 0; } .ciq-consent-banner-close { position: absolute; top: 12px; right: 12px; width: 24px; height: 24px; border: none; background: transparent; color: #9ca3af; cursor: pointer; padding: 0; display: flex; align-items: center; justify-content: center; border-radius: 4px; transition: all 0.2s; } .ciq-consent-banner-close:hover { background: #f3f4f6; color: #374151; } .ciq-consent-banner-close svg { width: 16px; height: 16px; } .ciq-btn-link { background: transparent; color: #3b82f6; border: none; padding: 8px 12px; font-size: 14px; font-weight: 500; cursor: pointer; text-decoration: underline; transition: color 0.2s; } .ciq-btn-link:hover { color: #2563eb; } .ciq-btn-small { padding: 6px 12px; font-size: 13px; min-width: 70px; } /* Mobile responsiveness for banner */ @media (max-width: 768px) { .ciq-consent-banner { padding: 16px; } .ciq-consent-banner-content { flex-direction: column; align-items: stretch; gap: 16px; } .ciq-consent-banner-actions { flex-direction: column; width: 100%; } .ciq-consent-banner-actions .ciq-btn { width: 100%; } } /* Dark mode for banner */ @media (prefers-color-scheme: dark) { .ciq-consent-banner { background: #1f2937; border-top-color: #374151; } .ciq-consent-banner-text h4 { color: #f9fafb; } .ciq-consent-banner-text p { color: #d1d5db; } .ciq-consent-banner-close { color: #9ca3af; } .ciq-consent-banner-close:hover { background: #374151; color: #f9fafb; } } `; } /** * Sanitize text content to prevent XSS */ private sanitizeText(text: string): string { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } /** * Create a modal component */ public createModal(config: ModalConfig): HTMLElement { const modalId = 'modal_' + Date.now() + '_' + (++this.componentCounter); const overlay = document.createElement('div'); overlay.className = 'ciq-modal-overlay ciq-component'; if (config.className) { overlay.classList.add(config.className); } const modal = document.createElement('div'); modal.className = 'ciq-modal'; if (config.width) { modal.style.width = config.width; } if (config.height) { modal.style.height = config.height; } // Build modal structure safely if (config.title) { const header = document.createElement('div'); header.className = 'ciq-modal-header'; const title = document.createElement('h2'); title.className = 'ciq-modal-title'; title.textContent = config.title; // Safe - uses textContent header.appendChild(title); modal.appendChild(header); } const body = document.createElement('div'); body.className = 'ciq-modal-body'; // Only allow HTML if explicitly enabled (for internal use like consent UI) if (config.allowHTML) { body.innerHTML = config.content; } else { body.textContent = config.content; // Safe - uses textContent } modal.appendChild(body); overlay.appendChild(modal); // Handle click outside to close (unless persistent) if (!config.persistent) { overlay.addEventListener('click', (e) => { if (e.target === overlay) { this.hide(modalId); } }); // Handle escape key const handleEscape = (e: KeyboardEvent) => { if (e.key === 'Escape') { this.hide(modalId); document.removeEventListener('keydown', handleEscape); } }; document.addEventListener('keydown', handleEscape); } this.components.set(modalId, overlay); this.shadowRoot.appendChild(overlay); return overlay; } /** * Create a notification component */ public createNotification(config: NotificationConfig): HTMLElement { const notificationId = 'notification_' + Date.now() + '_' + (++this.componentCounter); const notification = document.createElement('div'); notification.className = `ciq-notification ciq-component ciq-${config.type || 'info'}`; const messageDiv = document.createElement('div'); messageDiv.className = 'ciq-notification-message'; messageDiv.textContent = config.message; // Safe - uses textContent notification.appendChild(messageDiv); this.components.set(notificationId, notification); this.shadowRoot.appendChild(notification); // Auto-hide after duration if (config.duration !== 0) { setTimeout(() => { this.hide(notificationId); }, config.duration || 4000); } return notification; } /** * Create a banner component (for consent, notifications, etc.) */ public createBanner(config: { content: string; className?: string; allowHTML?: boolean }): HTMLElement { const bannerId = 'banner_' + Date.now() + '_' + (++this.componentCounter); const banner = document.createElement('div'); banner.className = 'ciq-consent-banner ciq-component'; if (config.className) { banner.classList.add(config.className); } // Build banner content safely if (config.allowHTML) { banner.innerHTML = config.content; } else { banner.textContent = config.content; } this.components.set(bannerId, banner); this.shadowRoot.appendChild(banner); return banner; } /** * Show a component */ public show(component: HTMLElement): void { // Make shadow host visible this.shadowHost.style.cssText = ` position: fixed; top: 0; left: 0; right: 0; bottom: 0; pointer-events: none; z-index: 2147483647; `; // Show component with animation requestAnimationFrame(() => { component.classList.add('ciq-show'); }); } /** * Hide a component by ID or element */ public hide(componentIdOrElement: string | HTMLElement): void { let component: HTMLElement | undefined; let componentId: string | undefined; if (typeof componentIdOrElement === 'string') { componentId = componentIdOrElement; component = this.components.get(componentId); } else { component = componentIdOrElement; // Find the ID for (const [id, elem] of this.components.entries()) { if (elem === component) { componentId = id; break; } } } if (!component || !componentId) { return; } // Hide with animation component.classList.remove('ciq-show'); // Remove after animation setTimeout(() => { if (component && this.shadowRoot.contains(component)) { this.shadowRoot.removeChild(component); } if (componentId) { this.components.delete(componentId); } // Hide shadow host if no more components if (this.components.size === 0) { this.shadowHost.style.cssText = ` position: fixed; top: -9999px; left: -9999px; width: 0; height: 0; pointer-events: none; z-index: 2147483647; `; } }, 200); } /** * Hide all components */ public hideAll(): void { const componentIds = Array.from(this.components.keys()); componentIds.forEach(id => this.hide(id)); } /** * Update styles dynamically */ public updateStyles(css: string): void { if (this.styleSheet) { try { this.styleSheet.insertRule(css); } catch (error) { // Rule might already exist or be invalid } } else { // Fallback: add to existing style element const existingStyle = this.shadowRoot.querySelector('style'); if (existingStyle) { existingStyle.textContent += '\n' + css; } } } /** * Check if any components are visible */ public hasVisibleComponents(): boolean { return this.components.size > 0; } /** * Get component count */ public getComponentCount(): number { return this.components.size; } /** * Clean up and destroy the shadow DOM */ public destroy(): void { this.hideAll(); setTimeout(() => { if (this.shadowHost && this.shadowHost.parentNode) { this.shadowHost.parentNode.removeChild(this.shadowHost); } this.components.clear(); if (this.styleSheet) { this.styleSheet = null; } }, 300); } }