@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
text/typescript
/**
* 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);
}
}