import { mount } from "svelte"; import type { Component } from "svelte"; export type MountOptions< Props extends Record = Record, > = { component: Component; selector: string; useShadowDOM?: boolean; shadowDOMMode?: "open" | "closed"; getPropsFromElement?: (element: HTMLElement) => Props; onMountSuccess?: ( app: ReturnType, element: HTMLElement, shadowRoot?: ShadowRoot, ) => void; onMountError?: (error: unknown, element: HTMLElement) => void; }; /** * A utility for mounting Svelte components to DOM elements with support for: * - Initial mounting with delay * - Elementor popup support * - MutationObserver for dynamic content * - Preventing double-mounting * - Shadow DOM isolation */ export class ComponentMounter< Props extends Record = Record, > { private apps: ReturnType[] = []; private bodyObserver: MutationObserver | null = null; private options: MountOptions; private shadowRoots: ShadowRoot[] = []; constructor(options: MountOptions) { this.options = { useShadowDOM: false, shadowDOMMode: "open", ...options, }; // Initial mount with a delay to ensure DOM is ready this.mountAllElements(); // Set up MutationObserver to detect dynamically added elements this.setupMutationObserver(); } /** * Mount components on all matching elements */ public mountAllElements(): void { const elements = document.querySelectorAll( this.options.selector, ); if (elements.length > 0) { const newApps = this.mountComponentsOnElements(elements); this.apps = [...this.apps, ...newApps]; } } /** * Mount components on specific elements */ private mountComponentsOnElements( elements: NodeListOf, ): ReturnType[] { const mountedApps: ReturnType[] = []; for (const element of elements) { // Skip if element already has a mounted component if (element.dataset.mounted === "true") { continue; } // Get props from element using the provided function or empty object const props = this.options.getPropsFromElement ? this.options.getPropsFromElement(element) : ({} as Props); // Mount the Svelte component with props try { let mountTarget: HTMLElement | ShadowRoot = element; let shadowRoot: ShadowRoot | undefined; // Create shadow DOM if enabled if (this.options.useShadowDOM) { shadowRoot = element.attachShadow({ mode: this.options.shadowDOMMode || "open", }); mountTarget = shadowRoot; this.shadowRoots.push(shadowRoot); // Create a portal container inside the shadow DOM for dialogs/modals const portalContainer = document.createElement("div"); portalContainer.id = "portal-container"; portalContainer.style.position = "relative"; portalContainer.style.zIndex = "9999"; shadowRoot.appendChild(portalContainer); // Make the portal container available globally for bits-ui // This allows dialogs to render inside the shadow DOM instead of document.body if (!window.__productBirdShadowPortalContainer) { window.__productBirdShadowPortalContainer = portalContainer; } } const app = mount(this.options.component, { target: mountTarget, props, }); // Mark element as mounted element.dataset.mounted = "true"; // Add to apps array mountedApps.push(app); // Call onMountSuccess callback if provided if (this.options.onMountSuccess) { this.options.onMountSuccess(app, element, shadowRoot); } } catch (error) { console.error("Error mounting component:", error); // Call onMountError callback if provided if (this.options.onMountError) { this.options.onMountError(error, element); } } } return mountedApps; } /** * Set up a MutationObserver to detect dynamically added elements */ private setupMutationObserver(): void { this.bodyObserver = new MutationObserver((mutations) => { let shouldMount = false; for (const mutation of mutations) { if (mutation.type === "childList" && mutation.addedNodes.length > 0) { // Check if any of the added nodes contain our target elements for (const node of mutation.addedNodes) { if (node.nodeType === Node.ELEMENT_NODE) { const element = node as Element; if ( element.matches(this.options.selector) || element.querySelector(this.options.selector) ) { shouldMount = true; } } } } } if (shouldMount) { this.mountAllElements(); } }); // Start observing the body for changes this.bodyObserver.observe(document.body, { childList: true, subtree: true, }); } /** * Cleanup method to disconnect observers and unmount components */ public destroy(): void { // Disconnect the observer if (this.bodyObserver) { this.bodyObserver.disconnect(); } // Unmount all apps for (const app of this.apps) { if (typeof app.$destroy === "function") { app.$destroy(); } } // Clear shadow roots array (they'll be garbage collected when their host elements are removed) this.shadowRoots = []; this.apps = []; } /** * Get all mounted apps */ public getMountedApps(): ReturnType[] { return this.apps; } /** * Get all created shadow roots */ public getShadowRoots(): ShadowRoot[] { return this.shadowRoots; } } /** * Helper function to inject CSS into a shadow root */ export function injectCSSIntoShadowRoot( shadowRoot: ShadowRoot, css: string, ): void { const style = document.createElement("style"); style.textContent = css; shadowRoot.appendChild(style); } /** * Helper function to inject CSS from a URL into a shadow root */ export async function injectCSSFromURL( shadowRoot: ShadowRoot, url: string, ): Promise { try { const response = await fetch(url); const css = await response.text(); injectCSSIntoShadowRoot(shadowRoot, css); } catch (error) { console.error("Failed to load CSS from URL:", url, error); } } /** * Helper function to create a component mounter instance */ export function createComponentMounter< Props extends Record = Record, >(options: MountOptions): ComponentMounter { return new ComponentMounter(options); } /** * Helper function to create a shadow DOM component mounter with CSS injection */ export function createShadowComponentMounter< Props extends Record = Record, >( options: Omit, "useShadowDOM"> & { css?: string; cssUrls?: string[]; }, ): ComponentMounter { const { css, cssUrls, ...mountOptions } = options; return new ComponentMounter({ ...mountOptions, useShadowDOM: true, onMountSuccess: (app, element, shadowRoot) => { // Inject CSS if provided if (shadowRoot) { if (css) { injectCSSIntoShadowRoot(shadowRoot, css); } if (cssUrls) { for (const url of cssUrls) { injectCSSFromURL(shadowRoot, url); } } } // Call the original onMountSuccess if provided if (options.onMountSuccess) { options.onMountSuccess(app, element, shadowRoot); } }, }); }