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

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