# HTMX Conventions This document outlines the standardized HTMX patterns and conventions used in ThrillWiki. ## Request Attributes ### hx-get / hx-post / hx-put / hx-delete Use the appropriate HTTP method for the action: ```html
...
...
``` ### hx-target Specify where the response should be inserted: ```html
Content
``` ### hx-swap Control how content is inserted: ```html
...
...
...
...
``` ### hx-trigger Define when requests are made: ```html
...
Load More
...
``` ## Response Headers ### Server-Side Response Helpers Use the standardized HTMX utility functions in views: ```python from apps.core.htmx_utils import ( htmx_success, htmx_error, htmx_redirect, htmx_modal_close, htmx_refresh, ) def create_park(request): # On success return htmx_success('Park created successfully!') # On error return htmx_error('Validation failed', status=422) # Redirect return htmx_redirect('/parks/') # Close modal with refresh return htmx_modal_close( message='Park saved!', refresh_target='#parks-list' ) ``` ### HX-Trigger Trigger client-side events from the server: ```python # Single event response['HX-Trigger'] = 'parkCreated' # Event with data response['HX-Trigger'] = json.dumps({ 'showToast': { 'type': 'success', 'message': 'Park created!' } }) # Multiple events response['HX-Trigger'] = json.dumps({ 'closeModal': True, 'refreshList': {'target': '#parks-list'}, 'showToast': {'type': 'success', 'message': 'Done!'} }) ``` ### HX-Redirect Perform client-side navigation: ```python response['HX-Redirect'] = '/parks/' ``` ### HX-Refresh Trigger a full page refresh: ```python response['HX-Refresh'] = 'true' ``` ### HX-Retarget / HX-Reswap Override target and swap method: ```python response['HX-Retarget'] = '#different-target' response['HX-Reswap'] = 'outerHTML' ``` ## Partial Templates ### Convention For each full template, create a corresponding partial: ``` templates/ parks/ list.html # Full page list_partial.html # Just the content (for HTMX) detail.html detail_partial.html ``` ### Usage with Decorator ```python from apps.core.htmx_utils import htmx_partial @htmx_partial('parks/list.html') def park_list(request): parks = Park.objects.all() return ({'parks': parks},) # Returns context tuple ``` ### Manual Detection ```python from apps.core.htmx_utils import is_htmx_request def park_list(request): parks = Park.objects.all() if is_htmx_request(request): return render(request, 'parks/list_partial.html', {'parks': parks}) return render(request, 'parks/list.html', {'parks': parks}) ``` ## Loading States ### Indicator Attribute ```html
``` ### CSS Classes HTMX automatically adds classes during requests: ```css /* Added to element making request */ .htmx-request { opacity: 0.5; pointer-events: none; } /* Added to indicator elements */ .htmx-indicator { display: none; } .htmx-request .htmx-indicator, .htmx-request.htmx-indicator { display: inline-block; } ``` ### Skeleton Screens Use skeleton components for better UX: ```html
{% include 'components/skeletons/card_grid_skeleton.html' with cards=6 %}
``` ## Form Handling ### Standard Form Pattern ```html
{% csrf_token %} {% include 'forms/partials/form_field.html' with field=form.name %} {% include 'forms/partials/form_field.html' with field=form.location %}
Saving...
``` ### Inline Validation ```html
``` ### Server Response ```python from apps.core.htmx_utils import htmx_validation_response def validate_name(request): name = request.POST.get('name', '') if not name: return htmx_validation_response('name', errors=['Name is required']) if Park.objects.filter(name=name).exists(): return htmx_validation_response('name', errors=['Name already exists']) return htmx_validation_response('name', success_message='Name is available') ``` ## Pagination ### Load More Pattern ```html
{% for park in parks %} {% include 'parks/_card.html' %} {% endfor %}
{% if page_obj.has_next %} {% endif %} ``` ### Infinite Scroll ```html {% if page_obj.has_next %}
Loading...
{% endif %} ``` ### Traditional Pagination ```html {% include 'components/pagination.html' with page_obj=page_obj hx_target='#parks-list' hx_swap='innerHTML' hx_push_url='true' %} ``` ## Modal Integration ### Opening Modal with Content ```html
``` ### Closing Modal from Server ```python def save_park(request, pk): park = get_object_or_404(Park, pk=pk) form = ParkForm(request.POST, instance=park) if form.is_valid(): form.save() return htmx_modal_close( message='Park updated successfully!', refresh_target='#park-detail' ) return render(request, 'parks/_edit_form.html', {'form': form}) ``` ## Out-of-Band Updates ### Multiple Element Updates ```html
Updated content here
5
``` ### Server-Side OOB ```python def update_item(request, pk): item = get_object_or_404(Item, pk=pk) # ... update logic ... # Render multiple fragments main_html = render_to_string('items/_detail.html', {'item': item}) count_html = render_to_string('items/_count.html', {'count': Item.objects.count()}) return HttpResponse( main_html + f'
{count_html}
' ) ``` ## Error Handling ### HTTP Status Codes HTMX handles different status codes: - **2xx**: Normal swap - **204**: No swap (success, no content) - **4xx**: Swap (show validation errors) - **5xx**: Trigger error event ### Client-Side Error Handling ```javascript document.body.addEventListener('htmx:responseError', (event) => { const status = event.detail.xhr.status; if (status === 401) { Alpine.store('toast').error('Please log in to continue'); window.location.href = '/login/'; } else if (status >= 500) { Alpine.store('toast').error('Server error. Please try again.'); } }); ``` ### Validation Errors Return form HTML with errors on 422: ```python def create_park(request): form = ParkForm(request.POST) if not form.is_valid(): return render( request, 'parks/_form.html', {'form': form}, status=422 ) park = form.save() return htmx_success(f'{park.name} created!') ``` ## Best Practices ### 1. Use Semantic URLs ```html ``` ### 2. Include CSRF Token ```html ``` ### 3. Use hx-confirm for Destructive Actions ```html ``` ### 4. Preserve Scroll Position ```html
``` ### 5. Push URL for Navigation ```html View Park ``` ### 6. Handle Browser Back Button ```javascript window.addEventListener('popstate', (event) => { if (event.state && event.state.htmx) { htmx.ajax('GET', window.location.href, '#main'); } }); ```