docs(htmx): add htmx 4 integration guide for Nebula
This commit is contained in:
570
HTMX.md
Normal file
570
HTMX.md
Normal file
@@ -0,0 +1,570 @@
|
||||
# HTMX 4 Integration Guide for Nebula
|
||||
|
||||
This document details htmx 4 enhancements and patterns optimized for our Go/Templ server-driven architecture.
|
||||
|
||||
## Why htmx 4?
|
||||
|
||||
htmx 4 introduces significant architectural improvements that align perfectly with Go-based server rendering:
|
||||
|
||||
- **Built-in SSE streaming** - Native support for `text/event-stream` responses
|
||||
- **Morphing swaps** - Preserve DOM state during updates (forms, video, focus)
|
||||
- **Partial tags** - Explicit multi-target updates in single responses
|
||||
- **View Transitions API** - Smooth animated page transitions
|
||||
- **Simplified extensions** - Page-wide, event-based, zero performance penalty
|
||||
- **Enhanced request headers** - Better server-side request detection
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
```html
|
||||
<!-- htmx 4 core -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/htmx.org@4.0.0-alpha5/dist/htmx.min.js"></script>
|
||||
|
||||
<!-- Extensions (bundled with htmx 4) -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/htmx.org@4.0.0-alpha5/dist/ext/hx-preload.js"></script>
|
||||
```
|
||||
|
||||
**Note:** htmx 4 extensions are bundled in the htmx.org package at `/dist/ext/`. Do NOT use the separate `htmx-ext-*` packages which use the deprecated `defineExtension` API.
|
||||
|
||||
---
|
||||
|
||||
## Core Patterns for Go/Templ
|
||||
|
||||
### 1. Partial Updates with `<hx-partial>`
|
||||
|
||||
The `<hx-partial>` tag enables multiple targeted updates in a single response - perfect for Go handlers that need to update several page sections.
|
||||
|
||||
**Go Handler:**
|
||||
```go
|
||||
func handleAddToCart(w http.ResponseWriter, r *http.Request) {
|
||||
// Return multiple partials in one response
|
||||
components.CartPartials(cartData).Render(r.Context(), w)
|
||||
}
|
||||
```
|
||||
|
||||
**Templ Component:**
|
||||
```templ
|
||||
templ CartPartials(data CartData) {
|
||||
<hx-partial hx-target="#cart-count" hx-swap="innerHTML">
|
||||
<span>{ strconv.Itoa(data.Count) }</span>
|
||||
</hx-partial>
|
||||
|
||||
<hx-partial hx-target="#cart-items" hx-swap="beforeend">
|
||||
@CartItem(data.NewItem)
|
||||
</hx-partial>
|
||||
|
||||
<hx-partial hx-target="#cart-total" hx-swap="innerHTML">
|
||||
<span>{ data.FormattedTotal }</span>
|
||||
</hx-partial>
|
||||
}
|
||||
```
|
||||
|
||||
**HTML Trigger:**
|
||||
```html
|
||||
<button hx-post="/cart/add" hx-vals='{"product_id": "123"}'>
|
||||
Add to Cart
|
||||
</button>
|
||||
```
|
||||
|
||||
### 2. Morphing Swaps (Preserve State)
|
||||
|
||||
Use `innerMorph` or `outerMorph` to update content while preserving:
|
||||
- Form input values
|
||||
- Focus state
|
||||
- Video/audio playback
|
||||
- Scroll position
|
||||
|
||||
**Use Cases:**
|
||||
```html
|
||||
<!-- Live search that preserves input focus -->
|
||||
<input type="search"
|
||||
hx-get="/search"
|
||||
hx-trigger="input delay:300ms"
|
||||
hx-target="#results"
|
||||
hx-swap="innerMorph">
|
||||
|
||||
<!-- Update list without losing scroll position -->
|
||||
<div hx-get="/notifications"
|
||||
hx-trigger="every 30s"
|
||||
hx-swap="outerMorph">
|
||||
<!-- notifications list -->
|
||||
</div>
|
||||
|
||||
<!-- Dashboard that preserves playing media -->
|
||||
<div id="dashboard"
|
||||
hx-get="/dashboard/refresh"
|
||||
hx-trigger="every 60s"
|
||||
hx-swap="innerMorph">
|
||||
</div>
|
||||
```
|
||||
|
||||
**Morph Control Attributes:**
|
||||
```html
|
||||
<!-- Skip morphing specific elements -->
|
||||
<div hx-morph-skip>Never touch this</div>
|
||||
|
||||
<!-- Skip children but morph the element itself -->
|
||||
<div hx-morph-skip-children>
|
||||
<video><!-- preserved --></video>
|
||||
</div>
|
||||
|
||||
<!-- Ignore during morph comparisons -->
|
||||
<div hx-morph-ignore>Ephemeral content</div>
|
||||
```
|
||||
|
||||
### 3. View Transitions
|
||||
|
||||
Enable smooth animated transitions between page states.
|
||||
|
||||
**Global Enable:**
|
||||
```javascript
|
||||
htmx.config.transitions = true;
|
||||
```
|
||||
|
||||
**Per-Element:**
|
||||
```html
|
||||
<button hx-get="/page" hx-swap="innerHTML transition:true">
|
||||
Navigate
|
||||
</button>
|
||||
|
||||
<!-- With boosted links -->
|
||||
<nav hx-boost="transition:true">
|
||||
<a href="/dashboard">Dashboard</a>
|
||||
<a href="/settings">Settings</a>
|
||||
</nav>
|
||||
```
|
||||
|
||||
**CSS Customization:**
|
||||
```css
|
||||
::view-transition-group(*) {
|
||||
animation-duration: 150ms;
|
||||
}
|
||||
|
||||
::view-transition-old(root) {
|
||||
animation: fade-out 150ms ease-out;
|
||||
}
|
||||
|
||||
::view-transition-new(root) {
|
||||
animation: fade-in 150ms ease-in;
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Streaming Responses (SSE)
|
||||
|
||||
htmx 4 has native SSE support - no extension needed.
|
||||
|
||||
**Go Handler:**
|
||||
```go
|
||||
func handleProgress(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
|
||||
flusher := w.(http.Flusher)
|
||||
|
||||
for progress := 0; progress <= 100; progress += 10 {
|
||||
// Send HTML fragment
|
||||
fmt.Fprintf(w, "data: <div class=\"progress\" style=\"width: %d%%\"></div>\n\n", progress)
|
||||
flusher.Flush()
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
|
||||
// Final update
|
||||
fmt.Fprintf(w, "data: <div class=\"complete\">Done!</div>\n\n")
|
||||
}
|
||||
```
|
||||
|
||||
**HTML:**
|
||||
```html
|
||||
<button hx-get="/progress" hx-target="#progress-bar">
|
||||
Start Processing
|
||||
</button>
|
||||
<div id="progress-bar"></div>
|
||||
```
|
||||
|
||||
**Custom Events in SSE:**
|
||||
```go
|
||||
// Server sends custom event
|
||||
fmt.Fprintf(w, "event: notification\n")
|
||||
fmt.Fprintf(w, "data: {\"count\": 5}\n\n")
|
||||
```
|
||||
|
||||
```html
|
||||
<!-- Client handles custom event -->
|
||||
<div hx-get="/stream"
|
||||
hx-on:notification="updateBadge(event.detail.data)">
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Request Detection in Go
|
||||
|
||||
### htmx 4 Request Headers
|
||||
|
||||
| Header | Description | Example Value |
|
||||
|--------|-------------|---------------|
|
||||
| `HX-Request` | Always "true" for htmx requests | `true` |
|
||||
| `HX-Request-Type` | "partial" or "full" | `partial` |
|
||||
| `HX-Target` | Target element selector | `#results` |
|
||||
| `HX-Trigger` | Triggering element ID | `search-input` |
|
||||
| `HX-Boosted` | Request from boosted element | `true` |
|
||||
| `HX-Current-URL` | Current page URL | `/dashboard` |
|
||||
|
||||
**Go Middleware/Helper:**
|
||||
```go
|
||||
func IsHTMXRequest(r *http.Request) bool {
|
||||
return r.Header.Get("HX-Request") == "true"
|
||||
}
|
||||
|
||||
func IsPartialRequest(r *http.Request) bool {
|
||||
return r.Header.Get("HX-Request-Type") == "partial"
|
||||
}
|
||||
|
||||
func GetHTMXTarget(r *http.Request) string {
|
||||
return r.Header.Get("HX-Target")
|
||||
}
|
||||
```
|
||||
|
||||
**Pattern: Full Page vs Partial Response:**
|
||||
```go
|
||||
func handleDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
data := getDashboardData()
|
||||
|
||||
if IsHTMXRequest(r) && IsPartialRequest(r) {
|
||||
// Return only the content fragment
|
||||
views.DashboardContent(data).Render(r.Context(), w)
|
||||
return
|
||||
}
|
||||
|
||||
// Return full page with layout
|
||||
views.DashboardPage(data).Render(r.Context(), w)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Response Headers from Go
|
||||
|
||||
Control client behavior via response headers:
|
||||
|
||||
```go
|
||||
func handleAction(w http.ResponseWriter, r *http.Request) {
|
||||
// Redirect client-side (no full page reload)
|
||||
w.Header().Set("HX-Location", "/new-page")
|
||||
|
||||
// Push URL to browser history
|
||||
w.Header().Set("HX-Push-Url", "/updated-url")
|
||||
|
||||
// Replace current URL (no history entry)
|
||||
w.Header().Set("HX-Replace-Url", "/current")
|
||||
|
||||
// Trigger client-side events
|
||||
w.Header().Set("HX-Trigger", "itemAdded")
|
||||
w.Header().Set("HX-Trigger-After-Swap", "refreshCart")
|
||||
|
||||
// Override swap behavior
|
||||
w.Header().Set("HX-Reswap", "outerHTML")
|
||||
w.Header().Set("HX-Retarget", "#different-target")
|
||||
|
||||
// Force full page refresh
|
||||
w.Header().Set("HX-Refresh", "true")
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Attribute Inheritance
|
||||
|
||||
htmx 4 uses explicit inheritance with the `:inherited` modifier.
|
||||
|
||||
**Pattern: Shared Confirmation:**
|
||||
```html
|
||||
<div hx-confirm:inherited="Are you sure?">
|
||||
<button hx-delete="/item/1">Delete</button>
|
||||
<button hx-delete="/item/2">Delete</button>
|
||||
<button hx-delete="/item/3">Delete</button>
|
||||
</div>
|
||||
```
|
||||
|
||||
**Pattern: Shared Target:**
|
||||
```html
|
||||
<div hx-target:inherited="#results" hx-swap:inherited="innerHTML">
|
||||
<button hx-get="/page/1">Page 1</button>
|
||||
<button hx-get="/page/2">Page 2</button>
|
||||
<button hx-get="/page/3">Page 3</button>
|
||||
</div>
|
||||
```
|
||||
|
||||
**Enable Implicit Inheritance (htmx 2 behavior):**
|
||||
```javascript
|
||||
htmx.config.implicitInheritance = true;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Advanced Triggers
|
||||
|
||||
### Trigger Modifiers
|
||||
|
||||
```html
|
||||
<!-- Debounce input -->
|
||||
<input hx-get="/search"
|
||||
hx-trigger="input delay:500ms"
|
||||
hx-target="#results">
|
||||
|
||||
<!-- Throttle scroll -->
|
||||
<div hx-get="/load-more"
|
||||
hx-trigger="scroll throttle:1s"
|
||||
hx-target="#items">
|
||||
|
||||
<!-- Once only -->
|
||||
<div hx-get="/init" hx-trigger="load once">
|
||||
|
||||
<!-- Changed value only -->
|
||||
<select hx-get="/filter" hx-trigger="change changed">
|
||||
|
||||
<!-- From another element -->
|
||||
<button hx-get="/refresh" hx-trigger="click from:#other-button">
|
||||
```
|
||||
|
||||
### Special Events
|
||||
|
||||
```html
|
||||
<!-- Load when element enters DOM -->
|
||||
<div hx-get="/content" hx-trigger="load">
|
||||
|
||||
<!-- Load when scrolled into view -->
|
||||
<div hx-get="/lazy-content" hx-trigger="revealed">
|
||||
|
||||
<!-- Intersection observer with threshold -->
|
||||
<div hx-get="/analytics"
|
||||
hx-trigger="intersect threshold:0.5">
|
||||
|
||||
<!-- Polling -->
|
||||
<div hx-get="/status" hx-trigger="every 5s">
|
||||
|
||||
<!-- Load polling (self-replacing) -->
|
||||
<div hx-get="/progress"
|
||||
hx-trigger="load delay:1s"
|
||||
hx-swap="outerHTML">
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Event Handling with `hx-on`
|
||||
|
||||
Inline event handlers without separate JavaScript:
|
||||
|
||||
```html
|
||||
<!-- After request completes -->
|
||||
<form hx-post="/submit"
|
||||
hx-on:htmx:after:request="this.reset()">
|
||||
|
||||
<!-- Before request starts -->
|
||||
<button hx-post="/action"
|
||||
hx-on:htmx:before:request="showSpinner()">
|
||||
|
||||
<!-- On successful swap -->
|
||||
<div hx-get="/content"
|
||||
hx-on:htmx:after:swap="initComponents()">
|
||||
|
||||
<!-- Custom SSE events -->
|
||||
<div hx-get="/stream"
|
||||
hx-on:progress="updateBar(event.detail.data)">
|
||||
```
|
||||
|
||||
**Using `find()` helper:**
|
||||
```html
|
||||
<button hx-post="/save"
|
||||
hx-on:htmx:after:request="find('closest form').reset()">
|
||||
Save
|
||||
</button>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Request Synchronization
|
||||
|
||||
Prevent race conditions with `hx-sync`:
|
||||
|
||||
```html
|
||||
<!-- Form submission aborts pending validation -->
|
||||
<form hx-post="/submit" hx-sync="this:replace">
|
||||
<input hx-post="/validate"
|
||||
hx-trigger="change"
|
||||
hx-sync="closest form">
|
||||
<button type="submit">Submit</button>
|
||||
</form>
|
||||
|
||||
<!-- Queue requests -->
|
||||
<button hx-post="/action" hx-sync="this:queue">
|
||||
|
||||
<!-- Drop new requests while one is pending -->
|
||||
<button hx-post="/action" hx-sync="this:drop">
|
||||
|
||||
<!-- Abort pending, start new -->
|
||||
<button hx-post="/action" hx-sync="this:abort">
|
||||
```
|
||||
|
||||
**Programmatic Abort:**
|
||||
```html
|
||||
<button id="action-btn" hx-post="/long-action">Start</button>
|
||||
<button onclick="htmx.trigger('#action-btn', 'htmx:abort')">Cancel</button>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Loading Indicators
|
||||
|
||||
```html
|
||||
<!-- Inline indicator -->
|
||||
<button hx-get="/data">
|
||||
Load Data
|
||||
<span class="htmx-indicator">Loading...</span>
|
||||
</button>
|
||||
|
||||
<!-- External indicator -->
|
||||
<button hx-get="/data" hx-indicator="#spinner">
|
||||
Load Data
|
||||
</button>
|
||||
<div id="spinner" class="htmx-indicator">
|
||||
<wa-spinner></wa-spinner>
|
||||
</div>
|
||||
|
||||
<!-- Disable during request -->
|
||||
<button hx-get="/data" hx-disabled-elt="this">
|
||||
Load (disables while loading)
|
||||
</button>
|
||||
|
||||
<!-- Disable multiple elements -->
|
||||
<form hx-post="/submit" hx-disabled-elt="find input, find button">
|
||||
```
|
||||
|
||||
**CSS for indicators:**
|
||||
```css
|
||||
.htmx-indicator {
|
||||
opacity: 0;
|
||||
transition: opacity 200ms ease-in;
|
||||
}
|
||||
|
||||
.htmx-request .htmx-indicator,
|
||||
.htmx-request.htmx-indicator {
|
||||
opacity: 1;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Boosting & Navigation
|
||||
|
||||
Convert standard links/forms to AJAX:
|
||||
|
||||
```html
|
||||
<!-- Boost all links in nav -->
|
||||
<nav hx-boost:inherited="true">
|
||||
<a href="/dashboard">Dashboard</a>
|
||||
<a href="/settings">Settings</a>
|
||||
</nav>
|
||||
|
||||
<!-- With history support -->
|
||||
<a href="/page" hx-boost="true" hx-push-url="true">
|
||||
Navigate with History
|
||||
</a>
|
||||
|
||||
<!-- Boost with transitions -->
|
||||
<main hx-boost="transition:true">
|
||||
<a href="/other">Smooth Navigate</a>
|
||||
</main>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Preload Extension
|
||||
|
||||
Preload content on hover/mousedown for instant navigation:
|
||||
|
||||
```html
|
||||
<!-- Preload on mousedown (50ms head start) -->
|
||||
<a href="/page" preload="mousedown">Fast Link</a>
|
||||
|
||||
<!-- Preload on hover -->
|
||||
<a href="/page" preload>Preloaded Link</a>
|
||||
|
||||
<!-- Preload with custom trigger -->
|
||||
<button hx-get="/content" preload="mousedown">
|
||||
Load Content
|
||||
</button>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Confirmation Dialogs
|
||||
|
||||
```html
|
||||
<!-- Simple confirm -->
|
||||
<button hx-delete="/item" hx-confirm="Delete this item?">
|
||||
Delete
|
||||
</button>
|
||||
|
||||
<!-- Custom confirm with JavaScript -->
|
||||
<button hx-delete="/account"
|
||||
hx-confirm="js:confirmDelete()">
|
||||
Delete Account
|
||||
</button>
|
||||
|
||||
<script>
|
||||
async function confirmDelete() {
|
||||
const result = await showCustomDialog({
|
||||
title: 'Delete Account?',
|
||||
message: 'This cannot be undone.',
|
||||
confirmText: 'Delete',
|
||||
variant: 'danger'
|
||||
});
|
||||
return result.confirmed;
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Best Practices for Go/Templ
|
||||
|
||||
### 1. Use Partials for Multi-Target Updates
|
||||
Instead of multiple requests or complex OOB swaps, return `<hx-partial>` tags.
|
||||
|
||||
### 2. Leverage Morphing for Stateful Components
|
||||
Use `innerMorph`/`outerMorph` for forms, media players, and interactive widgets.
|
||||
|
||||
### 3. Detect Request Type Server-Side
|
||||
Return minimal HTML fragments for partial requests, full pages for direct navigation.
|
||||
|
||||
### 4. Use Response Headers for Side Effects
|
||||
Trigger client events, update URLs, and control swapping via headers.
|
||||
|
||||
### 5. Preload Critical Paths
|
||||
Add `preload="mousedown"` to navigation links for perceived instant loading.
|
||||
|
||||
### 6. Stream Long Operations
|
||||
Use SSE (`text/event-stream`) for progress indicators and real-time updates.
|
||||
|
||||
---
|
||||
|
||||
## Configuration Reference
|
||||
|
||||
```javascript
|
||||
// Enable all htmx 4 features
|
||||
htmx.config.transitions = true; // View Transitions API
|
||||
htmx.config.implicitInheritance = false; // Explicit :inherited (default)
|
||||
htmx.config.selfRequestsOnly = true; // Security: same-origin only
|
||||
htmx.config.timeout = 0; // Request timeout (0 = none)
|
||||
htmx.config.historyCacheSize = 10; // History cache entries
|
||||
```
|
||||
|
||||
**Via Meta Tag:**
|
||||
```html
|
||||
<meta name="htmx-config" content='{
|
||||
"transitions": true,
|
||||
"selfRequestsOnly": true
|
||||
}'>
|
||||
```
|
||||
Reference in New Issue
Block a user