Add standardized HTMX conventions, interaction patterns, and migration guide for ThrillWiki UX

This commit is contained in:
pacnpal
2025-12-22 16:56:27 -05:00
parent 2e35f8c5d9
commit ae31e889d7
144 changed files with 25792 additions and 4440 deletions

View File

@@ -1,44 +0,0 @@
/* Alert Styles */
.alert {
@apply fixed z-50 px-4 py-3 transition-all duration-500 transform rounded-lg shadow-lg right-4 top-4;
animation: slideIn 0.5s ease-out forwards;
}
.alert-success {
@apply text-white bg-green-500;
}
.alert-error {
@apply text-white bg-red-500;
}
.alert-info {
@apply text-white bg-blue-500;
}
.alert-warning {
@apply text-white bg-yellow-500;
}
/* Animation keyframes */
@keyframes slideIn {
0% {
transform: translateX(100%);
opacity: 0;
}
100% {
transform: translateX(0);
opacity: 1;
}
}
@keyframes slideOut {
0% {
transform: translateX(0);
opacity: 1;
}
100% {
transform: translateX(100%);
opacity: 0;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,17 +1,18 @@
@import "tailwindcss";
@theme {
--color-primary: #4f46e5;
--color-secondary: #e11d48;
--color-accent: #8b5cf6;
--font-family-sans: Poppins, sans-serif;
}
/**
* ThrillWiki Tailwind Input CSS
*
* This file imports Tailwind CSS and adds custom component styles.
* Color definitions are inherited from design-tokens.css.
* Do NOT define inline colors here - use design token variables instead.
*/
/* Base Component Styles */
.site-logo {
font-size: 1.5rem;
font-weight: 700;
background: linear-gradient(135deg, var(--color-primary), var(--color-secondary));
background: linear-gradient(135deg, var(--color-primary), var(--color-accent-500));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
@@ -36,18 +37,21 @@
padding: 0.5rem 0.75rem;
border-radius: 0.5rem;
font-weight: 500;
color: #6b7280;
color: var(--color-muted-foreground);
transition: all 0.2s ease;
text-decoration: none;
}
.nav-link:hover {
color: var(--color-primary);
background-color: rgba(79, 70, 229, 0.1);
background-color: var(--color-primary-100);
}
.nav-link i {
.nav-link i,
.nav-link svg {
font-size: 1rem;
width: 1rem;
height: 1rem;
}
@media (max-width: 640px) {
@@ -60,39 +64,38 @@
.form-input {
width: 100%;
padding: 0.75rem 1rem;
border: 1px solid #d1d5db;
border: 1px solid var(--color-border);
border-radius: 0.5rem;
font-size: 0.875rem;
background-color: white;
background-color: var(--color-background);
color: var(--color-foreground);
transition: all 0.2s ease;
}
.form-input:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1);
box-shadow: 0 0 0 3px var(--color-primary-100);
}
.form-input::placeholder {
color: #9ca3af;
color: var(--color-muted-foreground);
}
/* Dark mode form styles */
@media (prefers-color-scheme: dark) {
.form-input {
background-color: #374151;
border-color: #4b5563;
color: white;
}
.form-input:focus {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.2);
}
.form-input::placeholder {
color: #6b7280;
}
.dark .form-input {
background-color: var(--color-secondary-800);
border-color: var(--color-secondary-700);
color: var(--color-foreground);
}
.dark .form-input:focus {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px var(--color-primary-900);
}
.dark .form-input::placeholder {
color: var(--color-secondary-500);
}
/* Button Styles */
@@ -106,18 +109,18 @@
border-radius: 0.5rem;
font-size: 0.875rem;
font-weight: 600;
color: white;
background: linear-gradient(135deg, var(--color-primary), #3730a3);
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
color: var(--color-primary-foreground);
background: linear-gradient(135deg, var(--color-primary), var(--color-primary-700));
box-shadow: var(--shadow-sm);
transition: all 0.2s ease;
cursor: pointer;
text-decoration: none;
}
.btn-primary:hover {
background: linear-gradient(135deg, #3730a3, #312e81);
background: linear-gradient(135deg, var(--color-primary-700), var(--color-primary-800));
transform: translateY(-1px);
box-shadow: 0 6px 12px -2px rgba(0, 0, 0, 0.15);
box-shadow: var(--shadow-md);
}
.btn-primary:active {
@@ -130,23 +133,23 @@
justify-content: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
border: 1px solid #d1d5db;
border: 1px solid var(--color-border);
border-radius: 0.5rem;
font-size: 0.875rem;
font-weight: 600;
color: #374151;
background-color: white;
box-shadow: 0 2px 4px -1px rgba(0, 0, 0, 0.06);
color: var(--color-foreground);
background-color: var(--color-background);
box-shadow: var(--shadow-sm);
transition: all 0.2s ease;
cursor: pointer;
text-decoration: none;
}
.btn-secondary:hover {
background-color: #f9fafb;
border-color: #9ca3af;
background-color: var(--color-secondary-100);
border-color: var(--color-secondary-400);
transform: translateY(-1px);
box-shadow: 0 4px 8px -2px rgba(0, 0, 0, 0.1);
box-shadow: var(--shadow-md);
}
.btn-secondary:active {
@@ -154,17 +157,15 @@
}
/* Dark mode button styles */
@media (prefers-color-scheme: dark) {
.btn-secondary {
border-color: #4b5563;
color: #e5e7eb;
background-color: #374151;
}
.btn-secondary:hover {
background-color: #4b5563;
border-color: #6b7280;
}
.dark .btn-secondary {
border-color: var(--color-secondary-700);
color: var(--color-secondary-100);
background-color: var(--color-secondary-800);
}
.dark .btn-secondary:hover {
background-color: var(--color-secondary-700);
border-color: var(--color-secondary-600);
}
/* Menu Styles */
@@ -175,7 +176,7 @@
width: 100%;
padding: 0.75rem 1rem;
font-size: 0.875rem;
color: #374151;
color: var(--color-foreground);
background: none;
border: none;
text-align: left;
@@ -185,25 +186,24 @@
}
.menu-item:hover {
background-color: #f3f4f6;
background-color: var(--color-secondary-100);
color: var(--color-primary);
}
.menu-item i {
.menu-item i,
.menu-item svg {
width: 1.25rem;
text-align: center;
}
/* Dark mode menu styles */
@media (prefers-color-scheme: dark) {
.menu-item {
color: #e5e7eb;
}
.menu-item:hover {
background-color: #4b5563;
color: var(--color-primary);
}
.dark .menu-item {
color: var(--color-secondary-100);
}
.dark .menu-item:hover {
background-color: var(--color-secondary-700);
color: var(--color-primary);
}
/* Theme Toggle Styles */
@@ -216,24 +216,18 @@
}
.theme-toggle-btn:hover {
background-color: rgba(79, 70, 229, 0.1);
background-color: var(--color-primary-100);
}
.theme-toggle-btn i::before {
content: "\f185"; /* sun icon */
}
@media (prefers-color-scheme: dark) {
.theme-toggle-btn i::before {
content: "\f186"; /* moon icon */
}
.dark .theme-toggle-btn:hover {
background-color: var(--color-primary-900);
}
/* Mobile Menu Styles */
#mobileMenu {
display: none;
padding: 1rem 0;
border-top: 1px solid #e5e7eb;
border-top: 1px solid var(--color-border);
margin-top: 1rem;
}
@@ -241,10 +235,8 @@
display: block;
}
@media (prefers-color-scheme: dark) {
#mobileMenu {
border-top-color: #4b5563;
}
.dark #mobileMenu {
border-top-color: var(--color-secondary-700);
}
/* Grid Adaptive Styles */
@@ -274,30 +266,28 @@
/* Card Styles */
.card {
background: white;
background: var(--color-card);
border-radius: 0.75rem;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
box-shadow: var(--shadow-sm);
overflow: hidden;
transition: all 0.3s ease;
}
.card:hover {
transform: translateY(-2px);
box-shadow: 0 10px 25px -3px rgba(0, 0, 0, 0.1);
box-shadow: var(--shadow-lg);
}
@media (prefers-color-scheme: dark) {
.card {
background: #1f2937;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3);
}
.card:hover {
box-shadow: 0 10px 25px -3px rgba(0, 0, 0, 0.4);
}
.dark .card {
background: var(--color-card);
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3);
}
/* Alert Styles */
.dark .card:hover {
box-shadow: 0 10px 25px -3px rgba(0, 0, 0, 0.4);
}
/* Alert Styles - Using design tokens */
.alert {
position: fixed;
top: 1rem;
@@ -305,28 +295,28 @@
z-index: 50;
padding: 1rem 1.5rem;
border-radius: 0.5rem;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
box-shadow: var(--shadow-lg);
transition: all 0.3s ease;
animation: slideInRight 0.3s ease-out;
}
.alert-success {
background-color: #10b981;
background-color: var(--color-success-500);
color: white;
}
.alert-error {
background-color: #ef4444;
background-color: var(--color-error-500);
color: white;
}
.alert-info {
background-color: #3b82f6;
background-color: var(--color-info-500);
color: white;
}
.alert-warning {
background-color: #f59e0b;
background-color: var(--color-warning-500);
color: white;
}
@@ -351,7 +341,7 @@
display: inline-block;
width: 1rem;
height: 1rem;
border: 2px solid #f3f4f6;
border: 2px solid var(--color-secondary-200);
border-radius: 50%;
border-top-color: var(--color-primary);
animation: spin 1s ease-in-out infinite;
@@ -365,18 +355,18 @@
/* Utility Classes */
.text-gradient {
background: linear-gradient(135deg, var(--color-primary), var(--color-secondary));
background: linear-gradient(135deg, var(--color-primary), var(--color-accent-500));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.bg-gradient-primary {
background: linear-gradient(135deg, var(--color-primary), #3730a3);
background: linear-gradient(135deg, var(--color-primary), var(--color-primary-700));
}
.bg-gradient-secondary {
background: linear-gradient(135deg, var(--color-secondary), #be185d);
background: linear-gradient(135deg, var(--color-accent-500), var(--color-accent-700));
}
/* Responsive Utilities */
@@ -390,7 +380,7 @@
.lg\:flex {
display: flex !important;
}
.lg\:hidden {
display: none !important;
}
@@ -403,7 +393,11 @@
.focus-ring:focus {
outline: none;
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.2);
box-shadow: 0 0 0 3px var(--color-primary-200);
}
.dark .focus-ring:focus {
box-shadow: 0 0 0 3px var(--color-primary-800);
}
/* Animation Classes */

File diff suppressed because it is too large Load Diff

View 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;

View File

@@ -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');
}
};