/** * Progressive Enhancement for LiveComponents * * Provides automatic AJAX handling for links and forms within data-lc-boost containers. * Falls back gracefully to normal navigation/submit when JavaScript is disabled. * * Features: * - Automatic AJAX for links and forms * - Graceful degradation (works without JS) * - Integration with LiveComponents system * - SPA Router coordination (if available) */ export class ProgressiveEnhancement { constructor(liveComponentManager) { this.liveComponentManager = liveComponentManager; this.boostContainers = new Set(); this.initialized = false; } /** * Initialize progressive enhancement * * Sets up event listeners for boost containers and their links/forms. */ init() { if (this.initialized) { console.warn('[ProgressiveEnhancement] Already initialized'); return; } // Find all boost containers this.findBoostContainers(); // Setup mutation observer to handle dynamically added boost containers this.setupMutationObserver(); // Setup global click handler for links document.addEventListener('click', (e) => this.handleLinkClick(e), true); // Setup global submit handler for forms document.addEventListener('submit', (e) => this.handleFormSubmit(e), true); this.initialized = true; console.log('[ProgressiveEnhancement] Initialized'); } /** * Find all boost containers and setup handlers */ findBoostContainers() { const containers = document.querySelectorAll('[data-lc-boost="true"]'); containers.forEach(container => { this.setupBoostContainer(container); }); } /** * Setup boost container * * @param {HTMLElement} container - Boost container element */ setupBoostContainer(container) { if (this.boostContainers.has(container)) { return; // Already setup } this.boostContainers.add(container); // Mark links and forms as boosted (for identification) container.querySelectorAll('a[href], form[action]').forEach(element => { if (!element.hasAttribute('data-lc-boost')) { element.setAttribute('data-lc-boost', 'true'); } }); } /** * Setup mutation observer to detect new boost containers */ setupMutationObserver() { const observer = new MutationObserver((mutations) => { mutations.forEach((mutation) => { mutation.addedNodes.forEach((node) => { if (node.nodeType === Node.ELEMENT_NODE) { // Check if node itself is a boost container if (node.hasAttribute && node.hasAttribute('data-lc-boost') && node.getAttribute('data-lc-boost') === 'true') { this.setupBoostContainer(node); } // Check for boost containers within added node const boostContainers = node.querySelectorAll?.('[data-lc-boost="true"]'); if (boostContainers) { boostContainers.forEach(container => { this.setupBoostContainer(container); }); } // Check for links/forms within boost containers const links = node.querySelectorAll?.('a[href], form[action]'); if (links) { links.forEach(element => { const boostContainer = element.closest('[data-lc-boost="true"]'); if (boostContainer && !element.hasAttribute('data-lc-boost')) { element.setAttribute('data-lc-boost', 'true'); } }); } } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); } /** * Handle link clicks * * @param {Event} event - Click event */ handleLinkClick(event) { const link = event.target.closest('a[href]'); if (!link) return; // Check if link is in a boost container const boostContainer = link.closest('[data-lc-boost="true"]'); if (!boostContainer) return; // Check if link explicitly opts out if (link.hasAttribute('data-lc-boost') && link.getAttribute('data-lc-boost') === 'false') { return; // Let normal navigation happen } // Check for special link types that should not be boosted const href = link.getAttribute('href'); if (!href || href === '#' || href.startsWith('javascript:') || href.startsWith('mailto:') || href.startsWith('tel:')) { return; // Let normal behavior happen } // Check if target is _blank (new window) if (link.getAttribute('target') === '_blank') { return; // Let normal behavior happen } // Prevent default navigation event.preventDefault(); // Try to use SPA Router if available if (window.SPARouter && typeof window.SPARouter.navigate === 'function') { window.SPARouter.navigate(href); return; } // Fallback: Use fetch to load content this.loadContentViaAjax(href, link); } /** * Handle form submissions * * @param {Event} event - Submit event */ handleFormSubmit(event) { const form = event.target; if (!form || form.tagName !== 'FORM') return; // Check if form is in a boost container const boostContainer = form.closest('[data-lc-boost="true"]'); if (!boostContainer) return; // Check if form explicitly opts out if (form.hasAttribute('data-lc-boost') && form.getAttribute('data-lc-boost') === 'false') { return; // Let normal submit happen } // Check if form has data-live-action (handled by LiveComponents) if (form.hasAttribute('data-live-action')) { return; // Let LiveComponents handle it } // Prevent default submission event.preventDefault(); // Submit form via AJAX this.submitFormViaAjax(form); } /** * Load content via AJAX * * @param {string} url - URL to load * @param {HTMLElement} link - Link element that triggered the load */ async loadContentViaAjax(url, link) { try { // Show loading state link.setAttribute('aria-busy', 'true'); link.classList.add('lc-loading'); // Fetch content const response = await fetch(url, { method: 'GET', headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'text/html' } }); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const html = await response.text(); // Try to extract main content (look for
tag) const parser = new DOMParser(); const doc = parser.parseFromString(html, 'text/html'); const mainContent = doc.querySelector('main') || doc.body; // Update page content const currentMain = document.querySelector('main') || document.body; if (currentMain) { currentMain.innerHTML = mainContent.innerHTML; } else { document.body.innerHTML = mainContent.innerHTML; } // Update URL without reload window.history.pushState({}, '', url); // Reinitialize LiveComponents if (this.liveComponentManager) { const components = document.querySelectorAll('[data-live-component]'); components.forEach(component => { this.liveComponentManager.init(component); }); } // Dispatch custom event window.dispatchEvent(new CustomEvent('lc:boost:navigated', { detail: { url, link } })); } catch (error) { console.error('[ProgressiveEnhancement] Failed to load content:', error); // Fallback to normal navigation on error window.location.href = url; } finally { // Remove loading state link.removeAttribute('aria-busy'); link.classList.remove('lc-loading'); } } /** * Submit form via AJAX * * @param {HTMLFormElement} form - Form element */ async submitFormViaAjax(form) { try { // Show loading state form.setAttribute('aria-busy', 'true'); form.classList.add('lc-loading'); const formData = new FormData(form); const method = form.method.toUpperCase() || 'POST'; const action = form.action || window.location.href; // Fetch response const response = await fetch(action, { method: method, body: formData, headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'text/html' } }); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const html = await response.text(); // Check if response is a redirect if (response.redirected) { // Follow redirect await this.loadContentViaAjax(response.url, form); return; } // Try to extract main content const parser = new DOMParser(); const doc = parser.parseFromString(html, 'text/html'); const mainContent = doc.querySelector('main') || doc.body; // Update page content const currentMain = document.querySelector('main') || document.body; if (currentMain) { currentMain.innerHTML = mainContent.innerHTML; } else { document.body.innerHTML = mainContent.innerHTML; } // Update URL if form has action if (form.action && form.action !== window.location.href) { window.history.pushState({}, '', form.action); } // Reinitialize LiveComponents if (this.liveComponentManager) { const components = document.querySelectorAll('[data-live-component]'); components.forEach(component => { this.liveComponentManager.init(component); }); } // Dispatch custom event window.dispatchEvent(new CustomEvent('lc:boost:submitted', { detail: { form, action } })); } catch (error) { console.error('[ProgressiveEnhancement] Failed to submit form:', error); // Fallback to normal submit on error form.submit(); } finally { // Remove loading state form.removeAttribute('aria-busy'); form.classList.remove('lc-loading'); } } } export default ProgressiveEnhancement;