mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-23 17:51:09 -05:00
Add standardized HTMX conventions, interaction patterns, and migration guide for ThrillWiki UX
This commit is contained in:
File diff suppressed because it is too large
Load Diff
551
backend/static/js/form-validation.js
Normal file
551
backend/static/js/form-validation.js
Normal file
@@ -0,0 +1,551 @@
|
||||
/**
|
||||
* ThrillWiki Form Validation Module
|
||||
*
|
||||
* Provides client-side form validation helpers with HTMX integration.
|
||||
* Works with Alpine.js form components and Django form fields.
|
||||
*
|
||||
* Features:
|
||||
* - Debounced HTMX validation triggers
|
||||
* - Field state management (pristine, dirty, valid, invalid)
|
||||
* - Real-time validation feedback
|
||||
* - Integration with Alpine.js stores
|
||||
* - Accessible error announcements
|
||||
*/
|
||||
|
||||
// =============================================================================
|
||||
// Form Field State Management
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Field state constants
|
||||
*/
|
||||
const FieldState = {
|
||||
PRISTINE: 'pristine', // Field has not been touched
|
||||
DIRTY: 'dirty', // Field has been modified
|
||||
VALIDATING: 'validating', // Field is being validated
|
||||
VALID: 'valid', // Field passed validation
|
||||
INVALID: 'invalid', // Field failed validation
|
||||
};
|
||||
|
||||
/**
|
||||
* FormValidator class for managing form validation state
|
||||
*/
|
||||
class FormValidator {
|
||||
constructor(formElement, options = {}) {
|
||||
this.form = formElement;
|
||||
this.options = {
|
||||
validateOnBlur: true,
|
||||
validateOnChange: true,
|
||||
debounceMs: 500,
|
||||
showSuccessState: true,
|
||||
scrollToFirstError: true,
|
||||
...options,
|
||||
};
|
||||
|
||||
this.fields = new Map();
|
||||
this.debounceTimers = new Map();
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize form validation
|
||||
*/
|
||||
init() {
|
||||
if (!this.form) return;
|
||||
|
||||
// Find all form fields
|
||||
const inputs = this.form.querySelectorAll('input, textarea, select');
|
||||
inputs.forEach(input => {
|
||||
if (input.name) {
|
||||
this.registerField(input);
|
||||
}
|
||||
});
|
||||
|
||||
// Prevent default form submission if validation fails
|
||||
this.form.addEventListener('submit', (e) => {
|
||||
if (!this.validateAll()) {
|
||||
e.preventDefault();
|
||||
if (this.options.scrollToFirstError) {
|
||||
this.scrollToFirstError();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a field for validation
|
||||
*/
|
||||
registerField(input) {
|
||||
const fieldName = input.name;
|
||||
|
||||
this.fields.set(fieldName, {
|
||||
element: input,
|
||||
state: FieldState.PRISTINE,
|
||||
errors: [],
|
||||
touched: false,
|
||||
});
|
||||
|
||||
// Add event listeners
|
||||
if (this.options.validateOnBlur) {
|
||||
input.addEventListener('blur', () => this.onFieldBlur(fieldName));
|
||||
}
|
||||
|
||||
if (this.options.validateOnChange) {
|
||||
input.addEventListener('input', () => this.onFieldChange(fieldName));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle field blur event
|
||||
*/
|
||||
onFieldBlur(fieldName) {
|
||||
const field = this.fields.get(fieldName);
|
||||
if (!field) return;
|
||||
|
||||
field.touched = true;
|
||||
this.validateField(fieldName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle field change event with debouncing
|
||||
*/
|
||||
onFieldChange(fieldName) {
|
||||
const field = this.fields.get(fieldName);
|
||||
if (!field) return;
|
||||
|
||||
field.state = FieldState.DIRTY;
|
||||
this.updateFieldUI(fieldName);
|
||||
|
||||
// Debounce validation
|
||||
clearTimeout(this.debounceTimers.get(fieldName));
|
||||
this.debounceTimers.set(fieldName, setTimeout(() => {
|
||||
if (field.touched) {
|
||||
this.validateField(fieldName);
|
||||
}
|
||||
}, this.options.debounceMs));
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a single field
|
||||
*/
|
||||
validateField(fieldName) {
|
||||
const field = this.fields.get(fieldName);
|
||||
if (!field) return true;
|
||||
|
||||
field.state = FieldState.VALIDATING;
|
||||
this.updateFieldUI(fieldName);
|
||||
|
||||
const input = field.element;
|
||||
const errors = [];
|
||||
|
||||
// Built-in HTML5 validation
|
||||
if (!input.validity.valid) {
|
||||
if (input.validity.valueMissing) {
|
||||
errors.push(`${this.getFieldLabel(fieldName)} is required`);
|
||||
}
|
||||
if (input.validity.typeMismatch) {
|
||||
errors.push(`Please enter a valid ${input.type}`);
|
||||
}
|
||||
if (input.validity.tooShort) {
|
||||
errors.push(`Must be at least ${input.minLength} characters`);
|
||||
}
|
||||
if (input.validity.tooLong) {
|
||||
errors.push(`Must be no more than ${input.maxLength} characters`);
|
||||
}
|
||||
if (input.validity.patternMismatch) {
|
||||
errors.push(input.title || 'Please match the requested format');
|
||||
}
|
||||
if (input.validity.rangeUnderflow) {
|
||||
errors.push(`Must be at least ${input.min}`);
|
||||
}
|
||||
if (input.validity.rangeOverflow) {
|
||||
errors.push(`Must be no more than ${input.max}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Custom validation rules from data attributes
|
||||
if (input.dataset.validateUrl && input.value) {
|
||||
try {
|
||||
new URL(input.value);
|
||||
} catch {
|
||||
errors.push('Please enter a valid URL');
|
||||
}
|
||||
}
|
||||
|
||||
if (input.dataset.validateMatch) {
|
||||
const matchField = this.form.querySelector(`[name="${input.dataset.validateMatch}"]`);
|
||||
if (matchField && input.value !== matchField.value) {
|
||||
errors.push(`Must match ${this.getFieldLabel(input.dataset.validateMatch)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Update field state
|
||||
field.errors = errors;
|
||||
field.state = errors.length > 0 ? FieldState.INVALID : FieldState.VALID;
|
||||
this.updateFieldUI(fieldName);
|
||||
|
||||
return errors.length === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate all fields
|
||||
*/
|
||||
validateAll() {
|
||||
let isValid = true;
|
||||
|
||||
this.fields.forEach((field, fieldName) => {
|
||||
field.touched = true;
|
||||
if (!this.validateField(fieldName)) {
|
||||
isValid = false;
|
||||
}
|
||||
});
|
||||
|
||||
return isValid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update field UI based on state
|
||||
*/
|
||||
updateFieldUI(fieldName) {
|
||||
const field = this.fields.get(fieldName);
|
||||
if (!field) return;
|
||||
|
||||
const input = field.element;
|
||||
const wrapper = input.closest('.form-field');
|
||||
const feedback = wrapper?.querySelector('.field-feedback');
|
||||
|
||||
// Remove all state classes
|
||||
input.classList.remove(
|
||||
'border-red-500', 'border-green-500', 'border-gray-300',
|
||||
'focus:ring-red-500', 'focus:ring-green-500', 'focus:ring-blue-500'
|
||||
);
|
||||
|
||||
// Set aria attributes
|
||||
input.setAttribute('aria-invalid', field.state === FieldState.INVALID);
|
||||
|
||||
switch (field.state) {
|
||||
case FieldState.VALIDATING:
|
||||
input.classList.add('border-blue-300');
|
||||
break;
|
||||
|
||||
case FieldState.INVALID:
|
||||
input.classList.add('border-red-500', 'focus:ring-red-500');
|
||||
if (feedback) {
|
||||
feedback.innerHTML = this.renderErrors(field.errors);
|
||||
}
|
||||
break;
|
||||
|
||||
case FieldState.VALID:
|
||||
if (this.options.showSuccessState && field.touched) {
|
||||
input.classList.add('border-green-500', 'focus:ring-green-500');
|
||||
if (feedback) {
|
||||
feedback.innerHTML = this.renderSuccess();
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
input.classList.add('border-gray-300', 'focus:ring-blue-500');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render error messages HTML
|
||||
*/
|
||||
renderErrors(errors) {
|
||||
if (errors.length === 0) return '';
|
||||
|
||||
return `
|
||||
<ul class="text-sm text-red-600 dark:text-red-400 space-y-1 animate-slide-down" role="alert" aria-live="assertive">
|
||||
${errors.map(error => `
|
||||
<li class="flex items-start gap-1.5">
|
||||
<i class="fas fa-exclamation-circle mt-0.5 flex-shrink-0" aria-hidden="true"></i>
|
||||
<span>${error}</span>
|
||||
</li>
|
||||
`).join('')}
|
||||
</ul>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render success indicator HTML
|
||||
*/
|
||||
renderSuccess() {
|
||||
return `
|
||||
<div class="text-sm text-green-600 dark:text-green-400 flex items-center gap-1.5 animate-slide-down" role="status">
|
||||
<i class="fas fa-check-circle flex-shrink-0" aria-hidden="true"></i>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get field label text
|
||||
*/
|
||||
getFieldLabel(fieldName) {
|
||||
const field = this.fields.get(fieldName);
|
||||
if (!field) return fieldName;
|
||||
|
||||
const wrapper = field.element.closest('.form-field');
|
||||
const label = wrapper?.querySelector('label');
|
||||
return label?.textContent?.replace('*', '').trim() || fieldName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll to first error
|
||||
*/
|
||||
scrollToFirstError() {
|
||||
for (const [fieldName, field] of this.fields) {
|
||||
if (field.state === FieldState.INVALID) {
|
||||
field.element.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
field.element.focus();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set external errors (from server)
|
||||
*/
|
||||
setServerErrors(errors) {
|
||||
Object.entries(errors).forEach(([fieldName, messages]) => {
|
||||
const field = this.fields.get(fieldName);
|
||||
if (field) {
|
||||
field.errors = Array.isArray(messages) ? messages : [messages];
|
||||
field.state = FieldState.INVALID;
|
||||
field.touched = true;
|
||||
this.updateFieldUI(fieldName);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all errors
|
||||
*/
|
||||
clearErrors() {
|
||||
this.fields.forEach((field, fieldName) => {
|
||||
field.errors = [];
|
||||
field.state = FieldState.PRISTINE;
|
||||
this.updateFieldUI(fieldName);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset form to initial state
|
||||
*/
|
||||
reset() {
|
||||
this.clearErrors();
|
||||
this.fields.forEach(field => {
|
||||
field.touched = false;
|
||||
});
|
||||
this.form.reset();
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// HTMX Validation Integration
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Setup HTMX validation for a field
|
||||
* @param {HTMLElement} input - The input element
|
||||
* @param {string} validateUrl - URL for validation endpoint
|
||||
* @param {object} options - Configuration options
|
||||
*/
|
||||
function setupHTMXValidation(input, validateUrl, options = {}) {
|
||||
const defaults = {
|
||||
trigger: 'blur changed delay:500ms',
|
||||
indicator: true,
|
||||
targetSelector: null,
|
||||
};
|
||||
|
||||
const config = { ...defaults, ...options };
|
||||
|
||||
// Generate target ID
|
||||
const targetId = config.targetSelector || `#${input.name}-feedback`;
|
||||
|
||||
// Set HTMX attributes
|
||||
input.setAttribute('hx-post', validateUrl);
|
||||
input.setAttribute('hx-trigger', config.trigger);
|
||||
input.setAttribute('hx-target', targetId);
|
||||
input.setAttribute('hx-swap', 'innerHTML');
|
||||
|
||||
if (config.indicator) {
|
||||
const indicatorId = `#${input.name}-indicator`;
|
||||
input.setAttribute('hx-indicator', indicatorId);
|
||||
|
||||
// Create indicator if it doesn't exist
|
||||
const wrapper = input.closest('.form-field');
|
||||
if (wrapper && !wrapper.querySelector(indicatorId)) {
|
||||
const indicator = document.createElement('span');
|
||||
indicator.id = `${input.name}-indicator`;
|
||||
indicator.className = 'htmx-indicator absolute right-3 top-1/2 -translate-y-1/2';
|
||||
indicator.innerHTML = '<i class="fas fa-spinner fa-spin text-gray-400" aria-hidden="true"></i>';
|
||||
const inputWrapper = wrapper.querySelector('.relative') || wrapper;
|
||||
inputWrapper.appendChild(indicator);
|
||||
}
|
||||
}
|
||||
|
||||
// Process HTMX attributes
|
||||
if (typeof htmx !== 'undefined') {
|
||||
htmx.process(input);
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Alpine.js Integration
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Alpine.js form validation component
|
||||
* Usage: x-data="formValidation('/api/validate/')"
|
||||
*/
|
||||
document.addEventListener('alpine:init', () => {
|
||||
if (typeof Alpine === 'undefined') return;
|
||||
|
||||
Alpine.data('formValidation', (validateUrl = null) => ({
|
||||
fields: {},
|
||||
errors: {},
|
||||
touched: {},
|
||||
validating: {},
|
||||
submitted: false,
|
||||
|
||||
init() {
|
||||
// Find all form fields within this component
|
||||
this.$el.querySelectorAll('input, textarea, select').forEach(input => {
|
||||
if (input.name) {
|
||||
this.fields[input.name] = input.value;
|
||||
this.errors[input.name] = [];
|
||||
this.touched[input.name] = false;
|
||||
this.validating[input.name] = false;
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
async validateField(fieldName) {
|
||||
if (!validateUrl) return true;
|
||||
|
||||
this.validating[fieldName] = true;
|
||||
|
||||
try {
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content ||
|
||||
document.querySelector('[name=csrfmiddlewaretoken]')?.value;
|
||||
|
||||
const response = await fetch(validateUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': csrfToken,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
field: fieldName,
|
||||
value: this.fields[fieldName],
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.errors && data.errors[fieldName]) {
|
||||
this.errors[fieldName] = data.errors[fieldName];
|
||||
return false;
|
||||
} else {
|
||||
this.errors[fieldName] = [];
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Validation error:', error);
|
||||
return true; // Don't block on network errors
|
||||
} finally {
|
||||
this.validating[fieldName] = false;
|
||||
}
|
||||
},
|
||||
|
||||
onBlur(fieldName) {
|
||||
this.touched[fieldName] = true;
|
||||
this.validateField(fieldName);
|
||||
},
|
||||
|
||||
hasError(fieldName) {
|
||||
return this.touched[fieldName] && this.errors[fieldName]?.length > 0;
|
||||
},
|
||||
|
||||
isValid(fieldName) {
|
||||
return this.touched[fieldName] && this.errors[fieldName]?.length === 0;
|
||||
},
|
||||
|
||||
getErrors(fieldName) {
|
||||
return this.errors[fieldName] || [];
|
||||
},
|
||||
|
||||
setServerErrors(errors) {
|
||||
Object.entries(errors).forEach(([field, messages]) => {
|
||||
this.errors[field] = Array.isArray(messages) ? messages : [messages];
|
||||
this.touched[field] = true;
|
||||
});
|
||||
},
|
||||
|
||||
clearErrors() {
|
||||
Object.keys(this.errors).forEach(field => {
|
||||
this.errors[field] = [];
|
||||
});
|
||||
},
|
||||
|
||||
async validateAll() {
|
||||
let isValid = true;
|
||||
|
||||
for (const fieldName of Object.keys(this.fields)) {
|
||||
this.touched[fieldName] = true;
|
||||
const fieldValid = await this.validateField(fieldName);
|
||||
if (!fieldValid) isValid = false;
|
||||
}
|
||||
|
||||
return isValid;
|
||||
},
|
||||
|
||||
async submitForm(url, options = {}) {
|
||||
this.submitted = true;
|
||||
|
||||
const isValid = await this.validateAll();
|
||||
if (!isValid) {
|
||||
return { success: false, errors: this.errors };
|
||||
}
|
||||
|
||||
try {
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content ||
|
||||
document.querySelector('[name=csrfmiddlewaretoken]')?.value;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': csrfToken,
|
||||
...options.headers,
|
||||
},
|
||||
body: JSON.stringify(this.fields),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
if (data.errors) {
|
||||
this.setServerErrors(data.errors);
|
||||
}
|
||||
return { success: false, errors: data.errors || data };
|
||||
}
|
||||
|
||||
return { success: true, data };
|
||||
} catch (error) {
|
||||
console.error('Form submission error:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Global Exports
|
||||
// =============================================================================
|
||||
|
||||
window.FormValidator = FormValidator;
|
||||
window.setupHTMXValidation = setupHTMXValidation;
|
||||
window.FieldState = FieldState;
|
||||
@@ -7,6 +7,7 @@
|
||||
* - Mobile menu functionality
|
||||
* - Flash message handling
|
||||
* - Tooltip initialization
|
||||
* - Global HTMX loading state management
|
||||
*/
|
||||
|
||||
// =============================================================================
|
||||
@@ -171,15 +172,139 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
tooltipEl.className = 'absolute z-50 px-2 py-1 text-sm text-white bg-gray-900 rounded tooltip';
|
||||
tooltipEl.textContent = text;
|
||||
document.body.appendChild(tooltipEl);
|
||||
|
||||
|
||||
const rect = e.target.getBoundingClientRect();
|
||||
tooltipEl.style.top = rect.bottom + 5 + 'px';
|
||||
tooltipEl.style.left = rect.left + (rect.width - tooltipEl.offsetWidth) / 2 + 'px';
|
||||
});
|
||||
|
||||
|
||||
tooltip.addEventListener('mouseleave', () => {
|
||||
const tooltips = document.querySelectorAll('.tooltip');
|
||||
tooltips.forEach(t => t.remove());
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// HTMX Loading State Management
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Global HTMX Loading State Management
|
||||
*
|
||||
* Provides consistent loading state handling across the application:
|
||||
* - Adds 'htmx-loading' class to body during requests
|
||||
* - Manages button disabled states during form submissions
|
||||
* - Handles search input loading states with debouncing
|
||||
* - Provides skeleton screen swap utilities
|
||||
*/
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Track active HTMX requests
|
||||
let activeRequests = 0;
|
||||
|
||||
/**
|
||||
* Add global loading class to body during HTMX requests
|
||||
*/
|
||||
document.body.addEventListener('htmx:beforeRequest', (evt) => {
|
||||
activeRequests++;
|
||||
document.body.classList.add('htmx-loading');
|
||||
|
||||
// Disable submit buttons within the target element
|
||||
const target = evt.target;
|
||||
if (target.tagName === 'FORM' || target.closest('form')) {
|
||||
const form = target.tagName === 'FORM' ? target : target.closest('form');
|
||||
const submitBtn = form.querySelector('[type="submit"]');
|
||||
if (submitBtn) {
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.classList.add('htmx-request');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Remove global loading class when request completes
|
||||
*/
|
||||
document.body.addEventListener('htmx:afterRequest', (evt) => {
|
||||
activeRequests--;
|
||||
if (activeRequests <= 0) {
|
||||
activeRequests = 0;
|
||||
document.body.classList.remove('htmx-loading');
|
||||
}
|
||||
|
||||
// Re-enable submit buttons
|
||||
const target = evt.target;
|
||||
if (target.tagName === 'FORM' || target.closest('form')) {
|
||||
const form = target.tagName === 'FORM' ? target : target.closest('form');
|
||||
const submitBtn = form.querySelector('[type="submit"]');
|
||||
if (submitBtn) {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.classList.remove('htmx-request');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Handle search inputs with loading states
|
||||
* Automatically adds loading indicator to search inputs during HTMX requests
|
||||
*/
|
||||
document.querySelectorAll('input[type="search"], input[data-search]').forEach(input => {
|
||||
const wrapper = input.closest('.search-wrapper, .relative');
|
||||
if (!wrapper) return;
|
||||
|
||||
// Create loading indicator if it doesn't exist
|
||||
let indicator = wrapper.querySelector('.search-loading');
|
||||
if (!indicator) {
|
||||
indicator = document.createElement('span');
|
||||
indicator.className = 'search-loading htmx-indicator absolute right-3 top-1/2 -translate-y-1/2';
|
||||
indicator.innerHTML = '<i class="fas fa-spinner fa-spin text-muted-foreground"></i>';
|
||||
wrapper.appendChild(indicator);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Swap skeleton with content utility
|
||||
* Use data-skeleton-target to specify which skeleton to hide when content loads
|
||||
*/
|
||||
document.body.addEventListener('htmx:afterSwap', (evt) => {
|
||||
const skeletonTarget = evt.target.dataset.skeletonTarget;
|
||||
if (skeletonTarget) {
|
||||
const skeleton = document.querySelector(skeletonTarget);
|
||||
if (skeleton) {
|
||||
skeleton.style.display = 'none';
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Utility function to show skeleton and trigger HTMX load
|
||||
* @param {string} targetId - ID of the target element
|
||||
* @param {string} skeletonId - ID of the skeleton element
|
||||
*/
|
||||
window.showSkeletonAndLoad = function(targetId, skeletonId) {
|
||||
const target = document.getElementById(targetId);
|
||||
const skeleton = document.getElementById(skeletonId);
|
||||
|
||||
if (skeleton) {
|
||||
skeleton.style.display = 'block';
|
||||
}
|
||||
|
||||
if (target) {
|
||||
htmx.trigger(target, 'load');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Utility function to replace content with skeleton during reload
|
||||
* @param {string} targetId - ID of the target element
|
||||
* @param {string} skeletonHtml - HTML string of skeleton to show
|
||||
*/
|
||||
window.reloadWithSkeleton = function(targetId, skeletonHtml) {
|
||||
const target = document.getElementById(targetId);
|
||||
if (target && skeletonHtml) {
|
||||
// Store original content temporarily
|
||||
target.dataset.originalContent = target.innerHTML;
|
||||
target.innerHTML = skeletonHtml;
|
||||
htmx.trigger(target, 'load');
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user