mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-23 15:31:09 -05:00
Add standardized HTMX conventions, interaction patterns, and migration guide for ThrillWiki UX
This commit is contained in:
528
docs/ux/htmx-conventions.md
Normal file
528
docs/ux/htmx-conventions.md
Normal file
@@ -0,0 +1,528 @@
|
||||
# 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
|
||||
<!-- Read operations -->
|
||||
<button hx-get="/parks/">Load Parks</button>
|
||||
|
||||
<!-- Create operations -->
|
||||
<form hx-post="/parks/create/">...</form>
|
||||
|
||||
<!-- Update operations -->
|
||||
<form hx-put="/parks/{{ park.id }}/edit/">...</form>
|
||||
|
||||
<!-- Delete operations -->
|
||||
<button hx-delete="/parks/{{ park.id }}/">Delete</button>
|
||||
```
|
||||
|
||||
### hx-target
|
||||
|
||||
Specify where the response should be inserted:
|
||||
|
||||
```html
|
||||
<!-- Target by ID -->
|
||||
<button hx-get="/content/" hx-target="#content-area">Load</button>
|
||||
|
||||
<!-- Target closest ancestor -->
|
||||
<button hx-delete="/item/" hx-target="closest .item-row">Delete</button>
|
||||
|
||||
<!-- Target this element -->
|
||||
<div hx-get="/refresh/" hx-target="this">Content</div>
|
||||
```
|
||||
|
||||
### hx-swap
|
||||
|
||||
Control how content is inserted:
|
||||
|
||||
```html
|
||||
<!-- Replace inner HTML (default) -->
|
||||
<div hx-get="/content/" hx-swap="innerHTML">...</div>
|
||||
|
||||
<!-- Replace entire element -->
|
||||
<div hx-get="/content/" hx-swap="outerHTML">...</div>
|
||||
|
||||
<!-- Insert before/after -->
|
||||
<div hx-get="/content/" hx-swap="beforeend">...</div>
|
||||
<div hx-get="/content/" hx-swap="afterbegin">...</div>
|
||||
|
||||
<!-- Delete element -->
|
||||
<button hx-delete="/item/" hx-swap="delete">Remove</button>
|
||||
|
||||
<!-- No swap (for side effects only) -->
|
||||
<button hx-post="/action/" hx-swap="none">Action</button>
|
||||
```
|
||||
|
||||
### hx-trigger
|
||||
|
||||
Define when requests are made:
|
||||
|
||||
```html
|
||||
<!-- On click (default for buttons) -->
|
||||
<button hx-get="/content/" hx-trigger="click">Load</button>
|
||||
|
||||
<!-- On page load -->
|
||||
<div hx-get="/content/" hx-trigger="load">...</div>
|
||||
|
||||
<!-- When element becomes visible -->
|
||||
<div hx-get="/more/" hx-trigger="revealed">Load More</div>
|
||||
|
||||
<!-- On form input with debounce -->
|
||||
<input hx-get="/search/" hx-trigger="keyup changed delay:300ms">
|
||||
|
||||
<!-- On blur for validation -->
|
||||
<input hx-post="/validate/" hx-trigger="blur changed">
|
||||
|
||||
<!-- Custom event -->
|
||||
<div hx-get="/refresh/" hx-trigger="refresh from:body">...</div>
|
||||
```
|
||||
|
||||
## 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
|
||||
<button hx-get="/content/"
|
||||
hx-indicator="#loading-spinner">
|
||||
Load Content
|
||||
</button>
|
||||
|
||||
<div id="loading-spinner" class="htmx-indicator">
|
||||
<i class="fas fa-spinner fa-spin"></i>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 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
|
||||
<div id="parks-list"
|
||||
hx-get="/parks/"
|
||||
hx-trigger="load"
|
||||
hx-swap="innerHTML">
|
||||
{% include 'components/skeletons/card_grid_skeleton.html' with cards=6 %}
|
||||
</div>
|
||||
```
|
||||
|
||||
## Form Handling
|
||||
|
||||
### Standard Form Pattern
|
||||
|
||||
```html
|
||||
<form id="park-form"
|
||||
hx-post="{% url 'parks:create' %}"
|
||||
hx-target="#park-form"
|
||||
hx-swap="outerHTML"
|
||||
hx-indicator="#form-loading">
|
||||
{% csrf_token %}
|
||||
|
||||
{% include 'forms/partials/form_field.html' with field=form.name %}
|
||||
{% include 'forms/partials/form_field.html' with field=form.location %}
|
||||
|
||||
<div id="form-loading" class="htmx-indicator">
|
||||
Saving...
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary">Save</button>
|
||||
</form>
|
||||
```
|
||||
|
||||
### Inline Validation
|
||||
|
||||
```html
|
||||
<input type="text"
|
||||
name="name"
|
||||
hx-post="{% url 'parks:validate_name' %}"
|
||||
hx-trigger="blur changed delay:500ms"
|
||||
hx-target="#name-feedback"
|
||||
hx-swap="innerHTML">
|
||||
<div id="name-feedback"></div>
|
||||
```
|
||||
|
||||
### 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
|
||||
<div id="parks-list">
|
||||
{% for park in parks %}
|
||||
{% include 'parks/_card.html' %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% if page_obj.has_next %}
|
||||
<button hx-get="?page={{ page_obj.next_page_number }}"
|
||||
hx-target="#parks-list"
|
||||
hx-swap="beforeend"
|
||||
hx-select="#parks-list > *"
|
||||
class="btn btn-outline">
|
||||
Load More
|
||||
</button>
|
||||
{% endif %}
|
||||
```
|
||||
|
||||
### Infinite Scroll
|
||||
|
||||
```html
|
||||
{% if page_obj.has_next %}
|
||||
<div hx-get="?page={{ page_obj.next_page_number }}"
|
||||
hx-trigger="revealed"
|
||||
hx-target="this"
|
||||
hx-swap="outerHTML"
|
||||
hx-select="oob-swap">
|
||||
<div class="htmx-indicator text-center py-4">
|
||||
<i class="fas fa-spinner fa-spin"></i> Loading...
|
||||
</div>
|
||||
</div>
|
||||
{% 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
|
||||
<button hx-get="{% url 'parks:edit' park.id %}"
|
||||
hx-target="#modal-content"
|
||||
hx-swap="innerHTML"
|
||||
@click="showEditModal = true">
|
||||
Edit Park
|
||||
</button>
|
||||
|
||||
<div x-show="showEditModal" @close-modal.window="showEditModal = false">
|
||||
<div id="modal-content">
|
||||
<!-- Content loaded here -->
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 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
|
||||
<!-- Main response -->
|
||||
<div id="main-content">
|
||||
Updated content here
|
||||
</div>
|
||||
|
||||
<!-- Out-of-band updates -->
|
||||
<div id="notification-count" hx-swap-oob="true">
|
||||
<span class="badge">5</span>
|
||||
</div>
|
||||
|
||||
<div id="sidebar-status" hx-swap-oob="true">
|
||||
<span class="status">Online</span>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 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'<div id="item-count" hx-swap-oob="true">{count_html}</div>'
|
||||
)
|
||||
```
|
||||
|
||||
## 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
|
||||
<!-- Good -->
|
||||
<button hx-delete="/parks/123/">Delete</button>
|
||||
|
||||
<!-- Avoid -->
|
||||
<button hx-post="/parks/123/delete/">Delete</button>
|
||||
```
|
||||
|
||||
### 2. Include CSRF Token
|
||||
|
||||
```html
|
||||
<body hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'>
|
||||
```
|
||||
|
||||
### 3. Use hx-confirm for Destructive Actions
|
||||
|
||||
```html
|
||||
<button hx-delete="/parks/123/"
|
||||
hx-confirm="Are you sure you want to delete this park?">
|
||||
Delete
|
||||
</button>
|
||||
```
|
||||
|
||||
### 4. Preserve Scroll Position
|
||||
|
||||
```html
|
||||
<div hx-get="/parks/"
|
||||
hx-swap="innerHTML show:top">
|
||||
```
|
||||
|
||||
### 5. Push URL for Navigation
|
||||
|
||||
```html
|
||||
<a hx-get="/parks/123/"
|
||||
hx-target="#main"
|
||||
hx-push-url="true">
|
||||
View Park
|
||||
</a>
|
||||
```
|
||||
|
||||
### 6. Handle Browser Back Button
|
||||
|
||||
```javascript
|
||||
window.addEventListener('popstate', (event) => {
|
||||
if (event.state && event.state.htmx) {
|
||||
htmx.ajax('GET', window.location.href, '#main');
|
||||
}
|
||||
});
|
||||
```
|
||||
Reference in New Issue
Block a user