Files
michaelschiemer/resources/js/modules/livecomponent/ProgressiveEnhancement.js
2025-11-24 21:28:25 +01:00

348 lines
12 KiB
JavaScript

/**
* 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 <main> 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;