first commit
This commit is contained in:
651
public_web/docs/HTMX_PATTERNS.md
Normal file
651
public_web/docs/HTMX_PATTERNS.md
Normal file
@@ -0,0 +1,651 @@
|
||||
# HTMX Integration Patterns - Public Web Frontend
|
||||
|
||||
**Last Updated:** November 18, 2025
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
This document outlines HTMX usage patterns, best practices, and common implementations for the Code of Conquest web frontend.
|
||||
|
||||
**HTMX Benefits:**
|
||||
- Server-side rendering with dynamic interactions
|
||||
- No JavaScript framework overhead
|
||||
- Progressive enhancement (works without JS)
|
||||
- Clean separation of concerns
|
||||
- Natural integration with Flask/Jinja2
|
||||
|
||||
---
|
||||
|
||||
## Core HTMX Attributes
|
||||
|
||||
### hx-get / hx-post / hx-put / hx-delete
|
||||
|
||||
Make HTTP requests from HTML elements:
|
||||
|
||||
```html
|
||||
<!-- GET request -->
|
||||
<button hx-get="/api/v1/characters" hx-target="#characters">
|
||||
Load Characters
|
||||
</button>
|
||||
|
||||
<!-- POST request -->
|
||||
<form hx-post="/api/v1/characters" hx-target="#result">
|
||||
<!-- form fields -->
|
||||
<button type="submit">Create</button>
|
||||
</form>
|
||||
|
||||
<!-- DELETE request -->
|
||||
<button hx-delete="/api/v1/characters/{{ character_id }}"
|
||||
hx-target="closest .character-card"
|
||||
hx-swap="outerHTML">
|
||||
Delete
|
||||
</button>
|
||||
```
|
||||
|
||||
### hx-target
|
||||
|
||||
Specify where to insert the response:
|
||||
|
||||
```html
|
||||
<!-- CSS selector -->
|
||||
<button hx-get="/content" hx-target="#content-div">Load</button>
|
||||
|
||||
<!-- Relative selectors -->
|
||||
<button hx-delete="/item" hx-target="closest .item">Delete</button>
|
||||
<button hx-post="/append" hx-target="next .list">Add</button>
|
||||
|
||||
<!-- Special values -->
|
||||
<button hx-get="/modal" hx-target="body">Show Modal</button>
|
||||
```
|
||||
|
||||
### hx-swap
|
||||
|
||||
Control how content is swapped:
|
||||
|
||||
```html
|
||||
<!-- innerHTML (default) -->
|
||||
<div hx-get="/content" hx-target="#div" hx-swap="innerHTML"></div>
|
||||
|
||||
<!-- outerHTML (replace element itself) -->
|
||||
<div hx-get="/content" hx-target="#div" hx-swap="outerHTML"></div>
|
||||
|
||||
<!-- beforebegin (before target) -->
|
||||
<div hx-get="/content" hx-target="#div" hx-swap="beforebegin"></div>
|
||||
|
||||
<!-- afterbegin (first child) -->
|
||||
<div hx-get="/content" hx-target="#div" hx-swap="afterbegin"></div>
|
||||
|
||||
<!-- beforeend (last child) -->
|
||||
<div hx-get="/content" hx-target="#div" hx-swap="beforeend"></div>
|
||||
|
||||
<!-- afterend (after target) -->
|
||||
<div hx-get="/content" hx-target="#div" hx-swap="afterend"></div>
|
||||
|
||||
<!-- none (no swap, just trigger) -->
|
||||
<div hx-get="/trigger" hx-swap="none"></div>
|
||||
```
|
||||
|
||||
### hx-trigger
|
||||
|
||||
Specify what triggers the request:
|
||||
|
||||
```html
|
||||
<!-- Click (default for buttons) -->
|
||||
<button hx-get="/content">Click Me</button>
|
||||
|
||||
<!-- Change (default for inputs) -->
|
||||
<select hx-get="/filter" hx-trigger="change">...</select>
|
||||
|
||||
<!-- Custom events -->
|
||||
<div hx-get="/content" hx-trigger="customEvent">...</div>
|
||||
|
||||
<!-- Multiple triggers -->
|
||||
<div hx-get="/content" hx-trigger="mouseenter, focus">...</div>
|
||||
|
||||
<!-- Trigger modifiers -->
|
||||
<input hx-get="/search"
|
||||
hx-trigger="keyup changed delay:500ms"
|
||||
hx-target="#results">
|
||||
|
||||
<!-- Polling -->
|
||||
<div hx-get="/status" hx-trigger="every 5s">Status: ...</div>
|
||||
|
||||
<!-- Load once -->
|
||||
<div hx-get="/content" hx-trigger="load">...</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Form Submission
|
||||
|
||||
**Pattern:** Submit form via AJAX, replace form with result
|
||||
|
||||
```html
|
||||
<form hx-post="/api/v1/characters"
|
||||
hx-target="#form-container"
|
||||
hx-swap="outerHTML">
|
||||
|
||||
<input type="text" name="name" placeholder="Character Name" required>
|
||||
|
||||
<select name="class">
|
||||
<option value="vanguard">Vanguard</option>
|
||||
<option value="luminary">Luminary</option>
|
||||
</select>
|
||||
|
||||
<button type="submit">Create Character</button>
|
||||
</form>
|
||||
```
|
||||
|
||||
**Backend Response:**
|
||||
```python
|
||||
@characters_bp.route('/', methods=['POST'])
|
||||
def create_character():
|
||||
# Create character via API
|
||||
response = api_client.post('/characters', request.form)
|
||||
|
||||
if response.status_code == 200:
|
||||
character = response.json()['result']
|
||||
return render_template('partials/character_card.html', character=character)
|
||||
else:
|
||||
return render_template('partials/form_error.html', error=response.json()['error'])
|
||||
```
|
||||
|
||||
### Delete with Confirmation
|
||||
|
||||
**Pattern:** Confirm before deleting, remove element on success
|
||||
|
||||
```html
|
||||
<div class="character-card" id="char-{{ character.character_id }}">
|
||||
<h3>{{ character.name }}</h3>
|
||||
|
||||
<button hx-delete="/api/v1/characters/{{ character.character_id }}"
|
||||
hx-confirm="Are you sure you want to delete {{ character.name }}?"
|
||||
hx-target="closest .character-card"
|
||||
hx-swap="outerHTML swap:1s">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
```
|
||||
|
||||
**Backend Response (empty for delete):**
|
||||
```python
|
||||
@characters_bp.route('/<character_id>', methods=['DELETE'])
|
||||
def delete_character(character_id):
|
||||
api_client.delete(f'/characters/{character_id}')
|
||||
return '', 200 # Empty response removes element
|
||||
```
|
||||
|
||||
### Search/Filter
|
||||
|
||||
**Pattern:** Live search with debouncing
|
||||
|
||||
```html
|
||||
<input type="text"
|
||||
name="search"
|
||||
placeholder="Search characters..."
|
||||
hx-get="/characters/search"
|
||||
hx-trigger="keyup changed delay:500ms"
|
||||
hx-target="#character-list"
|
||||
hx-indicator="#search-spinner">
|
||||
|
||||
<span id="search-spinner" class="htmx-indicator">Searching...</span>
|
||||
|
||||
<div id="character-list">
|
||||
<!-- Results appear here -->
|
||||
</div>
|
||||
```
|
||||
|
||||
**Backend:**
|
||||
```python
|
||||
@characters_bp.route('/search')
|
||||
def search_characters():
|
||||
query = request.args.get('search', '')
|
||||
characters = api_client.get(f'/characters?search={query}')
|
||||
return render_template('partials/character_list.html', characters=characters)
|
||||
```
|
||||
|
||||
### Pagination
|
||||
|
||||
**Pattern:** Load more items
|
||||
|
||||
```html
|
||||
<div id="character-list">
|
||||
{% for character in characters %}
|
||||
{% include 'partials/character_card.html' %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% if has_more %}
|
||||
<button hx-get="/characters?page={{ page + 1 }}"
|
||||
hx-target="#character-list"
|
||||
hx-swap="beforeend"
|
||||
hx-select=".character-card">
|
||||
Load More
|
||||
</button>
|
||||
{% endif %}
|
||||
```
|
||||
|
||||
### Inline Edit
|
||||
|
||||
**Pattern:** Click to edit, save inline
|
||||
|
||||
```html
|
||||
<div class="character-name" id="name-{{ character.character_id }}">
|
||||
<span hx-get="/characters/{{ character.character_id }}/edit-name"
|
||||
hx-target="closest div"
|
||||
hx-swap="outerHTML">
|
||||
{{ character.name }} ✏️
|
||||
</span>
|
||||
</div>
|
||||
```
|
||||
|
||||
**Edit Form Response:**
|
||||
```html
|
||||
<div class="character-name" id="name-{{ character.character_id }}">
|
||||
<form hx-put="/characters/{{ character.character_id }}/name"
|
||||
hx-target="closest div"
|
||||
hx-swap="outerHTML">
|
||||
<input type="text" name="name" value="{{ character.name }}" autofocus>
|
||||
<button type="submit">Save</button>
|
||||
<button type="button" hx-get="/characters/{{ character.character_id }}/name" hx-target="closest div" hx-swap="outerHTML">Cancel</button>
|
||||
</form>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Modal Dialog
|
||||
|
||||
**Pattern:** Load modal content dynamically
|
||||
|
||||
```html
|
||||
<button hx-get="/characters/{{ character.character_id }}/details"
|
||||
hx-target="#modal-container"
|
||||
hx-swap="innerHTML">
|
||||
View Details
|
||||
</button>
|
||||
|
||||
<div id="modal-container"></div>
|
||||
```
|
||||
|
||||
**Modal Response:**
|
||||
```html
|
||||
<div class="modal" id="character-modal">
|
||||
<div class="modal-content">
|
||||
<span class="close" onclick="closeModal()">×</span>
|
||||
<h2>{{ character.name }}</h2>
|
||||
<!-- character details -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function closeModal() {
|
||||
document.getElementById('character-modal').remove();
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
### Tabs
|
||||
|
||||
**Pattern:** Tab switching without page reload
|
||||
|
||||
```html
|
||||
<div class="tabs">
|
||||
<button hx-get="/dashboard/overview"
|
||||
hx-target="#tab-content"
|
||||
class="tab active">
|
||||
Overview
|
||||
</button>
|
||||
<button hx-get="/dashboard/characters"
|
||||
hx-target="#tab-content"
|
||||
class="tab">
|
||||
Characters
|
||||
</button>
|
||||
<button hx-get="/dashboard/sessions"
|
||||
hx-target="#tab-content"
|
||||
class="tab">
|
||||
Sessions
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="tab-content">
|
||||
<!-- Tab content loads here -->
|
||||
</div>
|
||||
```
|
||||
|
||||
### Polling for Updates
|
||||
|
||||
**Pattern:** Auto-refresh session status
|
||||
|
||||
```html
|
||||
<div hx-get="/sessions/{{ session_id }}/status"
|
||||
hx-trigger="every 5s"
|
||||
hx-target="#session-status">
|
||||
Loading status...
|
||||
</div>
|
||||
```
|
||||
|
||||
**Conditional Polling (stop when complete):**
|
||||
```html
|
||||
<div hx-get="/sessions/{{ session_id }}/status"
|
||||
hx-trigger="every 5s[status !== 'completed']"
|
||||
hx-target="#session-status">
|
||||
Status: {{ session.status }}
|
||||
</div>
|
||||
```
|
||||
|
||||
### Infinite Scroll
|
||||
|
||||
**Pattern:** Load more as user scrolls
|
||||
|
||||
```html
|
||||
<div id="character-list">
|
||||
{% for character in characters %}
|
||||
{% include 'partials/character_card.html' %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% if has_more %}
|
||||
<div hx-get="/characters?page={{ page + 1 }}"
|
||||
hx-trigger="revealed"
|
||||
hx-target="#character-list"
|
||||
hx-swap="beforeend">
|
||||
Loading more...
|
||||
</div>
|
||||
{% endif %}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Advanced Patterns
|
||||
|
||||
### Dependent Dropdowns
|
||||
|
||||
**Pattern:** Update second dropdown based on first selection
|
||||
|
||||
```html
|
||||
<select name="class"
|
||||
hx-get="/characters/abilities"
|
||||
hx-target="#ability-select"
|
||||
hx-trigger="change">
|
||||
<option value="vanguard">Vanguard</option>
|
||||
<option value="luminary">Luminary</option>
|
||||
</select>
|
||||
|
||||
<div id="ability-select">
|
||||
<!-- Abilities dropdown loads here based on class -->
|
||||
</div>
|
||||
```
|
||||
|
||||
### Out of Band Swaps
|
||||
|
||||
**Pattern:** Update multiple areas from single response
|
||||
|
||||
```html
|
||||
<button hx-post="/combat/attack"
|
||||
hx-target="#combat-log"
|
||||
hx-swap="beforeend">
|
||||
Attack
|
||||
</button>
|
||||
|
||||
<div id="combat-log"><!-- Combat log --></div>
|
||||
<div id="character-hp">HP: 50/100</div>
|
||||
```
|
||||
|
||||
**Backend Response:**
|
||||
```html
|
||||
<!-- Primary swap target -->
|
||||
<div>You dealt 15 damage!</div>
|
||||
|
||||
<!-- Out of band swap -->
|
||||
<div id="character-hp" hx-swap-oob="true">HP: 45/100</div>
|
||||
```
|
||||
|
||||
### Optimistic UI
|
||||
|
||||
**Pattern:** Show result immediately, revert on error
|
||||
|
||||
```html
|
||||
<button hx-post="/characters/{{ character_id }}/favorite"
|
||||
hx-target="#favorite-btn"
|
||||
hx-swap="outerHTML"
|
||||
class="btn">
|
||||
⭐ Favorite
|
||||
</button>
|
||||
```
|
||||
|
||||
**Immediate feedback with CSS:**
|
||||
```css
|
||||
.htmx-request .htmx-indicator {
|
||||
display: inline;
|
||||
}
|
||||
```
|
||||
|
||||
### Progressive Enhancement
|
||||
|
||||
**Pattern:** Fallback to normal form submission
|
||||
|
||||
```html
|
||||
<form action="/characters" method="POST"
|
||||
hx-post="/characters"
|
||||
hx-target="#result">
|
||||
<!-- If HTMX fails, form still works via normal POST -->
|
||||
<input type="text" name="name">
|
||||
<button type="submit">Create</button>
|
||||
</form>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## HTMX + Appwrite Realtime
|
||||
|
||||
### Hybrid Approach
|
||||
|
||||
Use HTMX for user actions, Appwrite Realtime for server push updates:
|
||||
|
||||
```html
|
||||
<!-- HTMX for user actions -->
|
||||
<button hx-post="/sessions/{{ session_id }}/ready"
|
||||
hx-target="#lobby-status">
|
||||
Ready
|
||||
</button>
|
||||
|
||||
<!-- Appwrite Realtime for live updates -->
|
||||
<script>
|
||||
const client = new Appwrite.Client()
|
||||
.setEndpoint('{{ config.appwrite_endpoint }}')
|
||||
.setProject('{{ config.appwrite_project_id }}');
|
||||
|
||||
client.subscribe('channel', response => {
|
||||
// Trigger HTMX to reload specific section
|
||||
htmx.ajax('GET', '/sessions/{{ session_id }}/status', {
|
||||
target: '#session-status',
|
||||
swap: 'innerHTML'
|
||||
});
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Show Error Messages
|
||||
|
||||
```html
|
||||
<form hx-post="/characters"
|
||||
hx-target="#form-container"
|
||||
hx-target-error="#error-container">
|
||||
<!-- form fields -->
|
||||
</form>
|
||||
|
||||
<div id="error-container"></div>
|
||||
```
|
||||
|
||||
**Backend Error Response:**
|
||||
```python
|
||||
@characters_bp.route('/', methods=['POST'])
|
||||
def create_character():
|
||||
try:
|
||||
# Create character
|
||||
pass
|
||||
except ValidationError as e:
|
||||
response = make_response(render_template('partials/error.html', error=str(e)), 400)
|
||||
response.headers['HX-Retarget'] = '#error-container'
|
||||
return response
|
||||
```
|
||||
|
||||
### Retry on Failure
|
||||
|
||||
```html
|
||||
<div hx-get="/api/v1/data"
|
||||
hx-trigger="load, error from:body delay:5s">
|
||||
Loading...
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Loading Indicators
|
||||
|
||||
### Global Indicator
|
||||
|
||||
```html
|
||||
<style>
|
||||
.htmx-indicator {
|
||||
display: none;
|
||||
}
|
||||
.htmx-request .htmx-indicator {
|
||||
display: inline;
|
||||
}
|
||||
</style>
|
||||
|
||||
<button hx-post="/action" hx-indicator="#spinner">
|
||||
Submit
|
||||
</button>
|
||||
<span id="spinner" class="htmx-indicator">Loading...</span>
|
||||
```
|
||||
|
||||
### Inline Indicator
|
||||
|
||||
```html
|
||||
<button hx-get="/content">
|
||||
<span class="button-text">Load</span>
|
||||
<span class="htmx-indicator">Loading...</span>
|
||||
</button>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Use Semantic HTML
|
||||
```html
|
||||
<!-- Good -->
|
||||
<button hx-post="/action">Submit</button>
|
||||
|
||||
<!-- Avoid -->
|
||||
<div hx-post="/action">Submit</div>
|
||||
```
|
||||
|
||||
### 2. Provide Fallbacks
|
||||
```html
|
||||
<form action="/submit" method="POST" hx-post="/submit">
|
||||
<!-- Works without JavaScript -->
|
||||
</form>
|
||||
```
|
||||
|
||||
### 3. Use hx-indicator for Loading States
|
||||
```html
|
||||
<button hx-post="/action" hx-indicator="#spinner">
|
||||
Submit
|
||||
</button>
|
||||
<span id="spinner" class="htmx-indicator">⏳</span>
|
||||
```
|
||||
|
||||
### 4. Debounce Search Inputs
|
||||
```html
|
||||
<input hx-get="/search"
|
||||
hx-trigger="keyup changed delay:500ms">
|
||||
```
|
||||
|
||||
### 5. Use CSRF Protection
|
||||
```html
|
||||
<form hx-post="/action">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
</form>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Debugging
|
||||
|
||||
### HTMX Events
|
||||
|
||||
Listen to HTMX events for debugging:
|
||||
|
||||
```html
|
||||
<script>
|
||||
document.body.addEventListener('htmx:beforeRequest', (event) => {
|
||||
console.log('Before request:', event.detail);
|
||||
});
|
||||
|
||||
document.body.addEventListener('htmx:afterRequest', (event) => {
|
||||
console.log('After request:', event.detail);
|
||||
});
|
||||
|
||||
document.body.addEventListener('htmx:responseError', (event) => {
|
||||
console.error('Response error:', event.detail);
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
### HTMX Logger Extension
|
||||
|
||||
```html
|
||||
<script src="https://unpkg.com/htmx.org/dist/ext/debug.js"></script>
|
||||
<body hx-ext="debug">
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Performance Tips
|
||||
|
||||
### 1. Use hx-select to Extract Partial
|
||||
```html
|
||||
<button hx-get="/full-page"
|
||||
hx-select="#content-section"
|
||||
hx-target="#result">
|
||||
Load Section
|
||||
</button>
|
||||
```
|
||||
|
||||
### 2. Disable During Request
|
||||
```html
|
||||
<button hx-post="/action" hx-disable-during-request>
|
||||
Submit
|
||||
</button>
|
||||
```
|
||||
|
||||
### 3. Use hx-sync for Sequential Requests
|
||||
```html
|
||||
<div hx-sync="this:replace">
|
||||
<button hx-get="/content">Load</button>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- **[TEMPLATES.md](TEMPLATES.md)** - Template structure and conventions
|
||||
- **[TESTING.md](TESTING.md)** - Manual testing guide
|
||||
- **[/api/docs/API_REFERENCE.md](../../api/docs/API_REFERENCE.md)** - API endpoints
|
||||
- **[HTMX Official Docs](https://htmx.org/docs/)** - Complete HTMX documentation
|
||||
|
||||
---
|
||||
|
||||
**Document Version:** 1.1
|
||||
**Created:** November 18, 2025
|
||||
**Last Updated:** November 21, 2025
|
||||
Reference in New Issue
Block a user