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,7 +1,57 @@
{% comment %}
Field Error Component
=====================
Displays error messages for a form field with icon and proper accessibility.
Purpose:
Renders error messages in a consistent, accessible format with visual
indicators. Used within form_field.html or standalone.
Usage Examples:
Within form_field.html (automatic):
{% include 'forms/partials/field_error.html' with errors=field.errors %}
Standalone:
{% include 'forms/partials/field_error.html' with errors=form.email.errors %}
Non-field errors:
{% include 'forms/partials/field_error.html' with errors=form.non_field_errors %}
Parameters:
Required:
- errors: List of error messages (typically field.errors)
Optional:
- show_icon: Show error icon (default: True)
- animate: Add entrance animation (default: True)
- size: 'sm', 'md', 'lg' (default: 'sm')
Dependencies:
- Tailwind CSS for styling
- Font Awesome icons
Accessibility:
- Uses role="alert" for immediate screen reader announcement
- aria-live="assertive" for dynamic error display
- Error icon is decorative (aria-hidden)
{% endcomment %}
{% with show_icon=show_icon|default:True animate=animate|default:True size=size|default:'sm' %}
{% if errors %}
<ul class="field-errors">
{% for e in errors %}
<li>{{ e }}</li>
<ul class="{% if size == 'lg' %}text-base{% elif size == 'md' %}text-sm{% else %}text-xs{% endif %} text-red-600 dark:text-red-400 space-y-1 {% if animate %}animate-slide-down{% endif %}"
role="alert"
aria-live="assertive">
{% for error in errors %}
<li class="flex items-start gap-1.5">
{% if show_icon %}
<i class="fas fa-exclamation-circle mt-0.5 flex-shrink-0" aria-hidden="true"></i>
{% endif %}
<span>{{ error }}</span>
</li>
{% endfor %}
</ul>
</ul>
{% endif %}
{% endwith %}

View File

@@ -1 +1,48 @@
<div class="field-success" aria-hidden="true"></div>
{% comment %}
Field Success Component
=======================
Displays success message for a validated form field with icon.
Purpose:
Renders a success indicator when a field passes validation.
Typically used with HTMX inline validation.
Usage Examples:
After successful validation:
{% include 'forms/partials/field_success.html' with message='Username is available' %}
Simple checkmark (no message):
{% include 'forms/partials/field_success.html' %}
Parameters:
Optional:
- message: Success message text (if empty, shows only icon)
- show_icon: Show success icon (default: True)
- animate: Add entrance animation (default: True)
- size: 'sm', 'md', 'lg' (default: 'sm')
Dependencies:
- Tailwind CSS for styling
- Font Awesome icons
Accessibility:
- Uses role="status" for polite screen reader announcement
- aria-live="polite" for non-urgent updates
- Success icon is decorative (aria-hidden)
{% endcomment %}
{% with show_icon=show_icon|default:True animate=animate|default:True size=size|default:'sm' %}
<div class="{% if size == 'lg' %}text-base{% elif size == 'md' %}text-sm{% else %}text-xs{% endif %} text-green-600 dark:text-green-400 flex items-center gap-1.5 {% if animate %}animate-slide-down{% endif %}"
role="status"
aria-live="polite">
{% if show_icon %}
<i class="fas fa-check-circle flex-shrink-0" aria-hidden="true"></i>
{% endif %}
{% if message %}
<span>{{ message }}</span>
{% endif %}
</div>
{% endwith %}

View File

@@ -1,4 +1,135 @@
<div class="form-actions">
<button type="submit" class="btn-primary">Save</button>
<button type="button" class="btn-secondary" hx-trigger="click" hx-swap="none">Cancel</button>
{% comment %}
Form Actions Component
======================
Renders form submit and cancel buttons with consistent styling.
Purpose:
Provides a standardized form actions section with submit button,
optional cancel button, and loading state support.
Usage Examples:
Basic submit:
{% include 'forms/partials/form_actions.html' %}
With cancel:
{% include 'forms/partials/form_actions.html' with show_cancel=True cancel_url='/list/' %}
Custom text:
{% include 'forms/partials/form_actions.html' with submit_text='Save Changes' %}
With loading state:
{% include 'forms/partials/form_actions.html' with show_loading=True loading_id='submit-loading' %}
Right-aligned (default):
{% include 'forms/partials/form_actions.html' %}
Left-aligned:
{% include 'forms/partials/form_actions.html' with align='left' %}
HTMX form:
{% include 'forms/partials/form_actions.html' with hx_disable='true' %}
Parameters:
Optional (submit):
- submit_text: Submit button text (default: 'Save')
- submit_class: CSS class (default: 'btn-primary')
- submit_disabled: Disable submit button (default: False)
- submit_icon: Icon class for submit button (e.g., 'fas fa-check')
Optional (cancel):
- show_cancel: Show cancel button (default: False)
- cancel_url: URL for cancel link
- cancel_text: Cancel button text (default: 'Cancel')
- cancel_class: CSS class (default: 'btn-secondary')
Optional (loading):
- show_loading: Show loading indicator on submit (default: False)
- loading_id: ID for htmx-indicator (default: 'submit-loading')
Optional (layout):
- align: 'left', 'right', 'center', 'between' (default: 'right')
- show_border: Show top border (default: True)
Optional (HTMX):
- hx_disable: Add hx-disable on submit (default: False)
Dependencies:
- Tailwind CSS
- HTMX (optional, for loading states)
- Font Awesome (optional, for icons)
Accessibility:
- Submit button is properly typed
- Loading state announced to screen readers
{% endcomment %}
{% with submit_text=submit_text|default:'Save' cancel_text=cancel_text|default:'Cancel' align=align|default:'right' show_border=show_border|default:True %}
<div class="form-actions flex items-center gap-3 mt-6
{% if show_border %}pt-4 border-t border-gray-200 dark:border-gray-700{% endif %}
{% if align == 'left' %}justify-start
{% elif align == 'center' %}justify-center
{% elif align == 'between' %}justify-between
{% else %}justify-end{% endif %}">
{# Cancel button (left side for 'between' alignment) #}
{% if show_cancel and align == 'between' %}
{% if cancel_url %}
<a href="{{ cancel_url }}"
class="{{ cancel_class|default:'btn-secondary' }}">
{{ cancel_text }}
</a>
{% else %}
<button type="button"
class="{{ cancel_class|default:'btn-secondary' }}"
onclick="history.back()">
{{ cancel_text }}
</button>
{% endif %}
{% endif %}
{# Button group #}
<div class="flex items-center gap-3">
{# Cancel button (for non-between alignment) #}
{% if show_cancel and align != 'between' %}
{% if cancel_url %}
<a href="{{ cancel_url }}"
class="{{ cancel_class|default:'btn-secondary' }}">
{{ cancel_text }}
</a>
{% else %}
<button type="button"
class="{{ cancel_class|default:'btn-secondary' }}"
onclick="history.back()">
{{ cancel_text }}
</button>
{% endif %}
{% endif %}
{# Submit button #}
<button type="submit"
class="{{ submit_class|default:'btn-primary' }} relative"
{% if submit_disabled %}disabled{% endif %}
{% if hx_disable %}hx-disable{% endif %}>
{# Icon (optional) #}
{% if submit_icon %}
<i class="{{ submit_icon }} mr-2" aria-hidden="true"></i>
{% endif %}
{# Text #}
<span>{{ submit_text }}</span>
{# Loading indicator (optional) #}
{% if show_loading %}
<span id="{{ loading_id|default:'submit-loading' }}"
class="htmx-indicator ml-2">
<i class="fas fa-spinner fa-spin" aria-hidden="true"></i>
<span class="sr-only">Submitting...</span>
</span>
{% endif %}
</button>
</div>
</div>
{% endwith %}

View File

@@ -1,5 +1,198 @@
<div class="form-field" data-field-name="{{ field.name }}">
<label for="id_{{ field.name }}">{{ field.label }}</label>
{{ field }}
<div class="field-feedback" aria-live="polite">{% include "forms/partials/field_error.html" %}</div>
{% comment %}
Form Field Component
====================
A comprehensive form field component with label, input, help text, and error handling.
Purpose:
Renders a complete form field with consistent styling, accessibility attributes,
and optional HTMX validation support.
Usage Examples:
Basic field:
{% include 'forms/partials/form_field.html' with field=form.email %}
Field with custom label:
{% include 'forms/partials/form_field.html' with field=form.email label='Email Address' %}
Field without label:
{% include 'forms/partials/form_field.html' with field=form.hidden_field show_label=False %}
Field with HTMX validation:
{% include 'forms/partials/form_field.html' with field=form.username hx_validate=True hx_validate_url='/api/validate-username/' %}
Inline field:
{% include 'forms/partials/form_field.html' with field=form.email layout='inline' %}
With success state:
{% include 'forms/partials/form_field.html' with field=form.email show_success=True %}
Parameters:
Required:
- field: Django form field object
Optional (labels):
- label: Custom label text (default: field.label)
- show_label: Show label (default: True)
- label_class: Additional CSS classes for label
Optional (input):
- input_class: Additional CSS classes for input
- placeholder: Custom placeholder text
- autofocus: Add autofocus attribute (default: False)
- disabled: Disable input (default: False)
- readonly: Make input readonly (default: False)
Optional (help/errors):
- help_text: Custom help text (default: field.help_text)
- show_help: Show help text (default: True)
- show_errors: Show error messages (default: True)
- show_success: Show success indicator when valid (default: False)
Optional (HTMX validation):
- hx_validate: Enable HTMX inline validation (default: False)
- hx_validate_url: URL for validation endpoint
- hx_validate_trigger: Trigger event (default: 'blur changed delay:500ms')
- hx_validate_target: Target for validation response (default: auto-generated)
Optional (layout):
- layout: 'stacked' or 'inline' (default: 'stacked')
- required_indicator: Show required indicator (default: True)
- size: 'sm', 'md', 'lg' for input size (default: 'md')
Dependencies:
- Tailwind CSS for styling
- HTMX (optional, for inline validation)
- Alpine.js (optional, for enhanced interactions)
- common_filters template tags (for add_class filter)
Accessibility:
- Label properly associated with input via for/id
- aria-describedby links help text and errors to input
- aria-invalid set when field has errors
- aria-required set for required fields
- Error messages use role="alert" for screen reader announcement
{% endcomment %}
{% load common_filters %}
{% with show_label=show_label|default:True show_help=show_help|default:True show_errors=show_errors|default:True show_success=show_success|default:False required_indicator=required_indicator|default:True layout=layout|default:'stacked' size=size|default:'md' %}
<div class="form-field {% if layout == 'inline' %}flex items-center gap-4{% else %}mb-4{% endif %}"
data-field-name="{{ field.name }}"
data-field-required="{{ field.field.required|yesno:'true,false' }}"
{% if hx_validate %}x-data="{ valid: null, validating: false, touched: false }"{% endif %}>
{# Label #}
{% if show_label %}
<label for="{{ field.id_for_label }}"
class="{% if layout == 'inline' %}w-1/3 text-right{% else %}block mb-1.5{% endif %} {% if size == 'sm' %}text-xs{% elif size == 'lg' %}text-base{% else %}text-sm{% endif %} font-medium text-foreground {{ label_class }}">
{{ label|default:field.label }}
{% if required_indicator and field.field.required %}
<span class="text-destructive ml-0.5" aria-hidden="true">*</span>
<span class="sr-only">(required)</span>
{% endif %}
</label>
{% endif %}
{# Input wrapper #}
<div class="{% if layout == 'inline' and show_label %}flex-1{% else %}w-full{% endif %} relative">
{# Field input with enhanced attributes #}
{# Base widget classes for sizing, spacing, and transitions #}
{% with base_class='w-full border rounded-lg transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-0 bg-background text-foreground placeholder:text-muted-foreground' %}
{# Size classes #}
{% with size_class=size|default:'md'|yesno:'px-2.5 py-1.5 text-xs,px-3 py-2 text-sm,px-4 py-2.5 text-base' %}
{% if size == 'sm' %}{% with actual_size='px-2.5 py-1.5 text-xs' %}
{# Error-specific classes override border and focus ring colors #}
{% with error_class='border-destructive focus:ring-destructive/30 focus:border-destructive' %}
{# Success-specific classes #}
{% with success_class='border-green-500 focus:ring-green-500/30 focus:border-green-500' %}
{# Normal state classes for border and focus ring #}
{% with normal_class='border-input focus:ring-ring/30 focus:border-ring' %}
{# Render the field with base classes plus state-specific classes #}
{% if field.errors %}
{{ field|add_class:base_class|add_class:actual_size|add_class:error_class|add_class:input_class }}
{% else %}
{{ field|add_class:base_class|add_class:actual_size|add_class:normal_class|add_class:input_class }}
{% endif %}
{% endwith %}{% endwith %}{% endwith %}{% endwith %}
{% elif size == 'lg' %}{% with actual_size='px-4 py-2.5 text-base' %}
{% with error_class='border-destructive focus:ring-destructive/30 focus:border-destructive' %}
{% with success_class='border-green-500 focus:ring-green-500/30 focus:border-green-500' %}
{% with normal_class='border-input focus:ring-ring/30 focus:border-ring' %}
{% if field.errors %}
{{ field|add_class:base_class|add_class:actual_size|add_class:error_class|add_class:input_class }}
{% else %}
{{ field|add_class:base_class|add_class:actual_size|add_class:normal_class|add_class:input_class }}
{% endif %}
{% endwith %}{% endwith %}{% endwith %}{% endwith %}
{% else %}{% with actual_size='px-3 py-2 text-sm' %}
{% with error_class='border-destructive focus:ring-destructive/30 focus:border-destructive' %}
{% with success_class='border-green-500 focus:ring-green-500/30 focus:border-green-500' %}
{% with normal_class='border-input focus:ring-ring/30 focus:border-ring' %}
{% if field.errors %}
{{ field|add_class:base_class|add_class:actual_size|add_class:error_class|add_class:input_class }}
{% else %}
{{ field|add_class:base_class|add_class:actual_size|add_class:normal_class|add_class:input_class }}
{% endif %}
{% endwith %}{% endwith %}{% endwith %}{% endwith %}
{% endif %}
{% endwith %}
{% endwith %}
{# HTMX validation attributes (when enabled) #}
{% if hx_validate and hx_validate_url %}
<script>
(function() {
var el = document.getElementById('{{ field.id_for_label }}');
if (el) {
el.setAttribute('hx-post', '{{ hx_validate_url }}');
el.setAttribute('hx-trigger', '{{ hx_validate_trigger|default:"blur changed delay:500ms" }}');
el.setAttribute('hx-target', '#{{ field.name }}-feedback');
el.setAttribute('hx-swap', 'innerHTML');
el.setAttribute('hx-indicator', '#{{ field.name }}-indicator');
el.setAttribute('hx-include', '[name="{{ field.name }}"]');
if (typeof htmx !== 'undefined') htmx.process(el);
}
})();
</script>
{% endif %}
{# Validation indicator (for HTMX) - positioned inside input #}
{% if hx_validate %}
<div id="{{ field.name }}-indicator" class="htmx-indicator absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none">
<i class="fas fa-spinner fa-spin text-muted-foreground" aria-hidden="true"></i>
</div>
{% endif %}
{# Success indicator (shown when valid and no errors) #}
{% if show_success and not field.errors and field.value %}
<div class="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none">
<i class="fas fa-check-circle text-green-500" aria-hidden="true"></i>
</div>
{% endif %}
{# Feedback area (errors, success, help) #}
<div id="{{ field.name }}-feedback"
class="field-feedback mt-1.5"
aria-live="polite"
aria-atomic="true">
{# Error messages #}
{% if show_errors and field.errors %}
{% include 'forms/partials/field_error.html' with errors=field.errors %}
{% endif %}
{# Help text #}
{% if show_help and field.help_text %}
<p id="{{ field.name }}-help"
class="{% if size == 'sm' %}text-xs{% elif size == 'lg' %}text-sm{% else %}text-xs{% endif %} text-muted-foreground {% if field.errors %}mt-1{% endif %}">
{{ help_text|default:field.help_text }}
</p>
{% endif %}
</div>
</div>
</div>
{% endwith %}

View File

@@ -0,0 +1,194 @@
{% comment %}
Form Submission Feedback Component
==================================
Displays feedback after form submission with various states.
Purpose:
Provides consistent feedback UI for form submission results including
success confirmations, error summaries, and loading states.
Usage Examples:
Success state:
{% include 'forms/partials/form_submission_feedback.html' with state='success' message='Park saved successfully!' %}
Error state:
{% include 'forms/partials/form_submission_feedback.html' with state='error' message='Please fix the errors below' %}
Loading state:
{% include 'forms/partials/form_submission_feedback.html' with state='loading' message='Saving...' %}
With redirect countdown:
{% include 'forms/partials/form_submission_feedback.html' with state='success' message='Saved!' redirect_url='/parks/' redirect_seconds=3 %}
Parameters:
Required:
- state: 'success', 'error', 'warning', 'loading' (default: 'success')
- message: Main feedback message
Optional:
- title: Optional title text
- show_icon: Show status icon (default: True)
- show_actions: Show action buttons (default: True)
- primary_action_text: Primary button text (default: varies by state)
- primary_action_url: Primary button URL
- secondary_action_text: Secondary button text
- secondary_action_url: Secondary button URL
- redirect_url: URL to redirect to after countdown
- redirect_seconds: Seconds before redirect (default: 3)
- errors: List of error messages (for error state)
- animate: Enable animations (default: True)
Dependencies:
- Tailwind CSS for styling
- Alpine.js for countdown functionality
- Font Awesome icons
Accessibility:
- Uses appropriate ARIA roles based on state
- Announces changes to screen readers
{% endcomment %}
{% with state=state|default:'success' show_icon=show_icon|default:True show_actions=show_actions|default:True animate=animate|default:True redirect_seconds=redirect_seconds|default:3 %}
<div class="form-submission-feedback rounded-lg p-4 {% if animate %}animate-fade-in{% endif %}
{% if state == 'success' %}bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800
{% elif state == 'error' %}bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800
{% elif state == 'warning' %}bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800
{% elif state == 'loading' %}bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800
{% endif %}"
role="{% if state == 'error' %}alert{% else %}status{% endif %}"
aria-live="{% if state == 'error' %}assertive{% else %}polite{% endif %}"
{% if redirect_url %}
x-data="{ countdown: {{ redirect_seconds }} }"
x-init="setInterval(() => { if(countdown > 0) countdown--; else window.location.href = '{{ redirect_url }}'; }, 1000)"
{% endif %}>
<div class="flex {% if title or errors %}flex-col gap-3{% else %}items-center gap-3{% endif %}">
{# Icon #}
{% if show_icon %}
<div class="flex-shrink-0 {% if title or errors %}flex items-center gap-3{% endif %}">
{% if state == 'success' %}
<div class="w-10 h-10 rounded-full bg-green-100 dark:bg-green-800/30 flex items-center justify-center">
<i class="fas fa-check text-green-600 dark:text-green-400 text-lg" aria-hidden="true"></i>
</div>
{% elif state == 'error' %}
<div class="w-10 h-10 rounded-full bg-red-100 dark:bg-red-800/30 flex items-center justify-center">
<i class="fas fa-exclamation-circle text-red-600 dark:text-red-400 text-lg" aria-hidden="true"></i>
</div>
{% elif state == 'warning' %}
<div class="w-10 h-10 rounded-full bg-yellow-100 dark:bg-yellow-800/30 flex items-center justify-center">
<i class="fas fa-exclamation-triangle text-yellow-600 dark:text-yellow-400 text-lg" aria-hidden="true"></i>
</div>
{% elif state == 'loading' %}
<div class="w-10 h-10 rounded-full bg-blue-100 dark:bg-blue-800/30 flex items-center justify-center">
<i class="fas fa-spinner fa-spin text-blue-600 dark:text-blue-400 text-lg" aria-hidden="true"></i>
</div>
{% endif %}
{# Title (if provided) #}
{% if title %}
<h3 class="font-semibold
{% if state == 'success' %}text-green-800 dark:text-green-200
{% elif state == 'error' %}text-red-800 dark:text-red-200
{% elif state == 'warning' %}text-yellow-800 dark:text-yellow-200
{% elif state == 'loading' %}text-blue-800 dark:text-blue-200
{% endif %}">
{{ title }}
</h3>
{% endif %}
</div>
{% endif %}
{# Content #}
<div class="flex-1 min-w-0">
{# Main message #}
<p class="text-sm
{% if state == 'success' %}text-green-700 dark:text-green-300
{% elif state == 'error' %}text-red-700 dark:text-red-300
{% elif state == 'warning' %}text-yellow-700 dark:text-yellow-300
{% elif state == 'loading' %}text-blue-700 dark:text-blue-300
{% endif %}">
{{ message }}
</p>
{# Error list (for error state) #}
{% if state == 'error' and errors %}
<ul class="mt-2 text-sm text-red-600 dark:text-red-400 space-y-1 list-disc list-inside">
{% for error in errors %}
<li>{{ error }}</li>
{% endfor %}
</ul>
{% endif %}
{# Redirect countdown #}
{% if redirect_url %}
<p class="mt-2 text-xs
{% if state == 'success' %}text-green-600 dark:text-green-400
{% else %}text-muted-foreground
{% endif %}">
Redirecting in <span x-text="countdown"></span> seconds...
<a href="{{ redirect_url }}" class="underline hover:no-underline ml-1">Go now</a>
</p>
{% endif %}
</div>
{# Actions #}
{% if show_actions and state != 'loading' %}
<div class="flex-shrink-0 flex items-center gap-2 {% if title or errors %}mt-2{% endif %}">
{% if state == 'error' %}
{# Error state actions #}
{% if secondary_action_url %}
<a href="{{ secondary_action_url }}"
class="btn btn-sm btn-ghost text-red-600 dark:text-red-400 hover:bg-red-100 dark:hover:bg-red-800/30">
{{ secondary_action_text|default:'Cancel' }}
</a>
{% endif %}
{% if primary_action_url %}
<a href="{{ primary_action_url }}"
class="btn btn-sm bg-red-600 hover:bg-red-700 text-white">
{{ primary_action_text|default:'Try Again' }}
</a>
{% else %}
<button type="submit"
class="btn btn-sm bg-red-600 hover:bg-red-700 text-white">
{{ primary_action_text|default:'Try Again' }}
</button>
{% endif %}
{% elif state == 'success' %}
{# Success state actions #}
{% if secondary_action_url %}
<a href="{{ secondary_action_url }}"
class="btn btn-sm btn-ghost text-green-600 dark:text-green-400 hover:bg-green-100 dark:hover:bg-green-800/30">
{{ secondary_action_text|default:'Add Another' }}
</a>
{% endif %}
{% if primary_action_url %}
<a href="{{ primary_action_url }}"
class="btn btn-sm bg-green-600 hover:bg-green-700 text-white">
{{ primary_action_text|default:'View' }}
</a>
{% endif %}
{% elif state == 'warning' %}
{# Warning state actions #}
{% if secondary_action_url %}
<a href="{{ secondary_action_url }}"
class="btn btn-sm btn-ghost">
{{ secondary_action_text|default:'Cancel' }}
</a>
{% endif %}
{% if primary_action_url %}
<a href="{{ primary_action_url }}"
class="btn btn-sm bg-yellow-600 hover:bg-yellow-700 text-white">
{{ primary_action_text|default:'Continue' }}
</a>
{% endif %}
{% endif %}
</div>
{% endif %}
</div>
</div>
{% endwith %}