mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-23 21:31:10 -05:00
Add standardized HTMX conventions, interaction patterns, and migration guide for ThrillWiki UX
This commit is contained in:
@@ -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
@@ -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
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