571 lines
13 KiB
Markdown
571 lines
13 KiB
Markdown
# 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
|
|
}'>
|
|
```
|