Some checks failed
Deploy Application / deploy (push) Has been cancelled
348 lines
12 KiB
JavaScript
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;
|
|
|
|
|
|
|
|
|