652 lines
14 KiB
Markdown
652 lines
14 KiB
Markdown
# 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
|