503 lines
15 KiB
Markdown
503 lines
15 KiB
Markdown
|
|
# HTMX Learning Guide
|
||
|
|
|
||
|
|
**Last Updated**: December 2024
|
||
|
|
|
||
|
|
## Overview
|
||
|
|
|
||
|
|
This document explains HTMX patterns used in this CV website project, with practical examples from the codebase. Use this as a learning resource for understanding HTMX concepts.
|
||
|
|
|
||
|
|
## Table of Contents
|
||
|
|
|
||
|
|
1. [Core Concepts](#core-concepts)
|
||
|
|
2. [Out-of-Band Swaps (OOB)](#out-of-band-swaps-oob)
|
||
|
|
3. [Language Switch Pattern](#language-switch-pattern)
|
||
|
|
4. [Toggle Patterns](#toggle-patterns)
|
||
|
|
5. [Contact Form Pattern](#contact-form-pattern)
|
||
|
|
6. [Skeleton Loaders](#skeleton-loaders)
|
||
|
|
7. [Common Attributes Reference](#common-attributes-reference)
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Core Concepts
|
||
|
|
|
||
|
|
### What is HTMX?
|
||
|
|
|
||
|
|
HTMX allows you to build modern user interfaces with simple HTML attributes instead of JavaScript. It extends HTML with attributes that enable:
|
||
|
|
|
||
|
|
- **AJAX requests** from any element (not just links/forms)
|
||
|
|
- **Partial page updates** without full page reloads
|
||
|
|
- **CSS transitions** on swaps
|
||
|
|
- **WebSocket/SSE** support
|
||
|
|
|
||
|
|
### Basic Example
|
||
|
|
|
||
|
|
```html
|
||
|
|
<!-- Button that fetches content and replaces a target -->
|
||
|
|
<button hx-get="/api/content"
|
||
|
|
hx-target="#content-area"
|
||
|
|
hx-swap="innerHTML">
|
||
|
|
Load Content
|
||
|
|
</button>
|
||
|
|
|
||
|
|
<div id="content-area">
|
||
|
|
<!-- Content will be replaced here -->
|
||
|
|
</div>
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Out-of-Band Swaps (OOB)
|
||
|
|
|
||
|
|
### The Problem
|
||
|
|
|
||
|
|
Normal HTMX swaps can only update ONE target element. But what if you need to update MULTIPLE elements with a single request?
|
||
|
|
|
||
|
|
**Example**: When switching languages, we need to update:
|
||
|
|
- Language selector buttons (show which is active)
|
||
|
|
- Page 1 content (header, experience, education)
|
||
|
|
- Page 2 content (awards, projects, courses)
|
||
|
|
- Footer
|
||
|
|
|
||
|
|
### The Solution: `hx-swap-oob`
|
||
|
|
|
||
|
|
OOB (Out-of-Band) swaps let you update ANY element on the page by including it in your response with a matching `id`.
|
||
|
|
|
||
|
|
```html
|
||
|
|
<!-- Server response can include multiple elements -->
|
||
|
|
<!-- Main response goes to hx-target -->
|
||
|
|
<div>Main content here</div>
|
||
|
|
|
||
|
|
<!-- OOB elements update by matching ID -->
|
||
|
|
<div id="sidebar" hx-swap-oob="outerHTML">
|
||
|
|
New sidebar content
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div id="notification" hx-swap-oob="innerHTML">
|
||
|
|
5 new messages
|
||
|
|
</div>
|
||
|
|
```
|
||
|
|
|
||
|
|
### OOB Swap Types
|
||
|
|
|
||
|
|
| Attribute | Effect |
|
||
|
|
|-----------|--------|
|
||
|
|
| `hx-swap-oob="true"` | Replace inner HTML |
|
||
|
|
| `hx-swap-oob="innerHTML"` | Replace inner HTML |
|
||
|
|
| `hx-swap-oob="outerHTML"` | Replace entire element including itself |
|
||
|
|
| `hx-swap-oob="beforebegin"` | Insert before element |
|
||
|
|
| `hx-swap-oob="afterend"` | Insert after element |
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Language Switch Pattern
|
||
|
|
|
||
|
|
**File**: `templates/language-switch.html`
|
||
|
|
|
||
|
|
This is the most complex HTMX pattern in the project - updating the entire CV content when switching languages.
|
||
|
|
|
||
|
|
### How It Works
|
||
|
|
|
||
|
|
1. **User clicks language button** (EN or ES)
|
||
|
|
2. **HTMX sends request** to `/switch-language?lang=es`
|
||
|
|
3. **Server renders new content** for both pages in the selected language
|
||
|
|
4. **OOB swaps update** both page containers atomically
|
||
|
|
|
||
|
|
### The Template Structure
|
||
|
|
|
||
|
|
```html
|
||
|
|
<!-- templates/language-switch.html -->
|
||
|
|
|
||
|
|
<!-- First: Update language selector buttons (OOB) -->
|
||
|
|
<div id="language-selector-container" hx-swap-oob="outerHTML">
|
||
|
|
<button class="lang-btn {{if eq .Lang "en"}}active{{end}}"
|
||
|
|
hx-post="/switch-language?lang=en">EN</button>
|
||
|
|
<button class="lang-btn {{if eq .Lang "es"}}active{{end}}"
|
||
|
|
hx-post="/switch-language?lang=es">ES</button>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- Second: Update Page 1 content (OOB) -->
|
||
|
|
<div id="cv-inner-content-page-1"
|
||
|
|
class="cv-page-content-wrapper"
|
||
|
|
hx-swap-oob="outerHTML"
|
||
|
|
_="on htmx:afterSettle wait 100ms then remove .loading from me">
|
||
|
|
|
||
|
|
{{template "title-badges" .}}
|
||
|
|
|
||
|
|
<div class="page-content">
|
||
|
|
<!-- Left Sidebar -->
|
||
|
|
<aside class="cv-sidebar cv-sidebar-left">
|
||
|
|
{{range .SkillsLeft}}
|
||
|
|
<section>{{.Category}}: {{range .Items}}{{.}}{{end}}</section>
|
||
|
|
{{end}}
|
||
|
|
</aside>
|
||
|
|
|
||
|
|
<!-- Main Content -->
|
||
|
|
<main class="cv-main">
|
||
|
|
{{template "section-header" .}}
|
||
|
|
{{template "section-education" .}}
|
||
|
|
{{template "section-experience" .}}
|
||
|
|
</main>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- Third: Update Page 2 content (OOB) -->
|
||
|
|
<div id="cv-inner-content-page-2"
|
||
|
|
class="cv-page-content-wrapper"
|
||
|
|
hx-swap-oob="outerHTML"
|
||
|
|
_="on htmx:afterSettle wait 100ms then remove .loading from me">
|
||
|
|
|
||
|
|
{{template "title-badges" .}}
|
||
|
|
|
||
|
|
<div class="page-content">
|
||
|
|
<!-- Main Content -->
|
||
|
|
<main class="cv-main">
|
||
|
|
{{template "section-awards" .}}
|
||
|
|
{{template "section-projects" .}}
|
||
|
|
{{template "section-courses" .}}
|
||
|
|
</main>
|
||
|
|
|
||
|
|
<!-- Right Sidebar -->
|
||
|
|
<aside class="cv-sidebar cv-sidebar-right">
|
||
|
|
{{range .SkillsRight}}
|
||
|
|
<section>{{.Category}}: {{range .Items}}{{.}}{{end}}</section>
|
||
|
|
{{end}}
|
||
|
|
</aside>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{{template "cv-footer" .}}
|
||
|
|
</div>
|
||
|
|
```
|
||
|
|
|
||
|
|
### Why Two Separate Page Divs?
|
||
|
|
|
||
|
|
The CV is designed as a **2-page printable document**:
|
||
|
|
|
||
|
|
| Page 1 | Page 2 |
|
||
|
|
|--------|--------|
|
||
|
|
| Header | Awards |
|
||
|
|
| Education | Projects |
|
||
|
|
| Skills Summary | Courses |
|
||
|
|
| Experience | Languages |
|
||
|
|
| Left Sidebar (Skills) | References |
|
||
|
|
| | Right Sidebar (More Skills) |
|
||
|
|
| | Footer |
|
||
|
|
|
||
|
|
Each page has its own layout grid and sidebar positioning, so they need separate containers.
|
||
|
|
|
||
|
|
### The Hyperscript Integration
|
||
|
|
|
||
|
|
```html
|
||
|
|
_="on htmx:afterSettle wait 100ms then remove .loading from me"
|
||
|
|
```
|
||
|
|
|
||
|
|
This hyperscript:
|
||
|
|
1. Listens for `htmx:afterSettle` event (content fully swapped)
|
||
|
|
2. Waits 100ms (for CSS transitions)
|
||
|
|
3. Removes `.loading` class (hides skeleton, shows content)
|
||
|
|
|
||
|
|
### Visual Flow
|
||
|
|
|
||
|
|
```
|
||
|
|
┌─────────────────────────────────────────────────────────┐
|
||
|
|
│ User clicks "ES" │
|
||
|
|
└─────────────────────────────────────────────────────────┘
|
||
|
|
│
|
||
|
|
▼
|
||
|
|
┌─────────────────────────────────────────────────────────┐
|
||
|
|
│ hx-post="/switch-language?lang=es" │
|
||
|
|
│ hx-target="#cv-content" │
|
||
|
|
│ hx-swap="none" ← Response body ignored for main swap │
|
||
|
|
└─────────────────────────────────────────────────────────┘
|
||
|
|
│
|
||
|
|
▼
|
||
|
|
┌─────────────────────────────────────────────────────────┐
|
||
|
|
│ Server Response Contains: │
|
||
|
|
│ │
|
||
|
|
│ 1. Language selector (hx-swap-oob="outerHTML") │
|
||
|
|
│ 2. Page 1 content (hx-swap-oob="outerHTML") │
|
||
|
|
│ 3. Page 2 content (hx-swap-oob="outerHTML") │
|
||
|
|
└─────────────────────────────────────────────────────────┘
|
||
|
|
│
|
||
|
|
▼
|
||
|
|
┌─────────────────────────────────────────────────────────┐
|
||
|
|
│ HTMX matches IDs and swaps all three simultaneously │
|
||
|
|
│ → Atomic update, no flicker │
|
||
|
|
└─────────────────────────────────────────────────────────┘
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Toggle Patterns
|
||
|
|
|
||
|
|
### Pattern: `hx-swap="none"` + Cookies
|
||
|
|
|
||
|
|
Toggles (length, theme, icons) don't need server-rendered content because:
|
||
|
|
1. The server just needs to **set a cookie**
|
||
|
|
2. The frontend uses **hyperscript** to toggle UI state
|
||
|
|
|
||
|
|
```html
|
||
|
|
<!-- Toggle button -->
|
||
|
|
<button hx-post="/toggle/length"
|
||
|
|
hx-swap="none"
|
||
|
|
_="on htmx:afterRequest toggle .cv-short .cv-long on #cv-container">
|
||
|
|
Toggle Length
|
||
|
|
</button>
|
||
|
|
```
|
||
|
|
|
||
|
|
### Server Handler
|
||
|
|
|
||
|
|
```go
|
||
|
|
// Returns 204 No Content - body is ignored anyway
|
||
|
|
func (h *CVHandler) ToggleLength(w http.ResponseWriter, r *http.Request) {
|
||
|
|
// Toggle cookie value
|
||
|
|
prefs := middleware.GetPreferences(r)
|
||
|
|
newLength := "long"
|
||
|
|
if prefs.CVLength == "long" {
|
||
|
|
newLength = "short"
|
||
|
|
}
|
||
|
|
|
||
|
|
// Set cookie
|
||
|
|
middleware.SetPreferenceCookie(w, "cv-length", newLength)
|
||
|
|
|
||
|
|
// Return 204 - no body needed
|
||
|
|
w.WriteHeader(http.StatusNoContent)
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### Why This Works
|
||
|
|
|
||
|
|
1. `hx-swap="none"` tells HTMX to **ignore the response body**
|
||
|
|
2. The **cookie gets set** (browser handles this automatically)
|
||
|
|
3. **Hyperscript handles UI** changes locally
|
||
|
|
4. On **next page load**, server reads cookie and renders correctly
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Contact Form Pattern
|
||
|
|
|
||
|
|
**File**: `templates/partials/contact/contact-form.html`
|
||
|
|
|
||
|
|
### Pattern: `hx-target` + Partial Replacement
|
||
|
|
|
||
|
|
```html
|
||
|
|
<form hx-post="/api/contact"
|
||
|
|
hx-target="#contact-form-container"
|
||
|
|
hx-swap="innerHTML"
|
||
|
|
hx-indicator="#contact-spinner">
|
||
|
|
|
||
|
|
<input type="email" name="email" required>
|
||
|
|
<textarea name="message" required></textarea>
|
||
|
|
|
||
|
|
<button type="submit">
|
||
|
|
Send
|
||
|
|
<span id="contact-spinner" class="htmx-indicator">...</span>
|
||
|
|
</button>
|
||
|
|
</form>
|
||
|
|
```
|
||
|
|
|
||
|
|
### Server Responses
|
||
|
|
|
||
|
|
**Success**: Returns success partial
|
||
|
|
```html
|
||
|
|
<!-- templates/partials/contact/contact-success.html -->
|
||
|
|
<div class="contact-success">
|
||
|
|
<iconify-icon icon="mdi:check-circle"></iconify-icon>
|
||
|
|
<p>Message sent successfully!</p>
|
||
|
|
</div>
|
||
|
|
```
|
||
|
|
|
||
|
|
**Error**: Returns error partial
|
||
|
|
```html
|
||
|
|
<!-- templates/partials/contact/contact-error.html -->
|
||
|
|
<div class="contact-error">
|
||
|
|
<iconify-icon icon="mdi:alert-circle"></iconify-icon>
|
||
|
|
<p>{{.ErrorMessage}}</p>
|
||
|
|
</div>
|
||
|
|
```
|
||
|
|
|
||
|
|
### The `hx-indicator` Pattern
|
||
|
|
|
||
|
|
```html
|
||
|
|
hx-indicator="#contact-spinner"
|
||
|
|
```
|
||
|
|
|
||
|
|
HTMX automatically:
|
||
|
|
1. Adds `.htmx-request` class to indicator during request
|
||
|
|
2. Shows spinner via CSS: `.htmx-indicator { display: none } .htmx-request .htmx-indicator { display: inline }`
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Skeleton Loaders
|
||
|
|
|
||
|
|
### Pattern: CSS Class Toggle with Hyperscript
|
||
|
|
|
||
|
|
The skeleton loader system uses a **dual-state structure**:
|
||
|
|
|
||
|
|
```html
|
||
|
|
<div class="component-wrapper">
|
||
|
|
<!-- Real content - visible by default -->
|
||
|
|
<div class="actual-content">
|
||
|
|
<h1>John Smith</h1>
|
||
|
|
<p>Software Engineer</p>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- Skeleton - hidden by default -->
|
||
|
|
<div class="skeleton-content">
|
||
|
|
<div class="skeleton skeleton-name"></div>
|
||
|
|
<div class="skeleton skeleton-text"></div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
```
|
||
|
|
|
||
|
|
### CSS State Control
|
||
|
|
|
||
|
|
```css
|
||
|
|
/* Default: Show content, hide skeleton */
|
||
|
|
.component-wrapper .actual-content { opacity: 1; }
|
||
|
|
.component-wrapper .skeleton-content { opacity: 0; pointer-events: none; }
|
||
|
|
|
||
|
|
/* Loading: Hide content, show skeleton */
|
||
|
|
.component-wrapper.loading .actual-content { opacity: 0; }
|
||
|
|
.component-wrapper.loading .skeleton-content { opacity: 1; }
|
||
|
|
```
|
||
|
|
|
||
|
|
### Triggering Loading State
|
||
|
|
|
||
|
|
Before language switch:
|
||
|
|
```javascript
|
||
|
|
// Add .loading to show skeletons
|
||
|
|
document.querySelectorAll('.cv-page-content-wrapper').forEach(el => {
|
||
|
|
el.classList.add('loading');
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
After content loads (via hyperscript):
|
||
|
|
```html
|
||
|
|
_="on htmx:afterSettle wait 100ms then remove .loading from me"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Common Attributes Reference
|
||
|
|
|
||
|
|
### Request Attributes
|
||
|
|
|
||
|
|
| Attribute | Description | Example |
|
||
|
|
|-----------|-------------|---------|
|
||
|
|
| `hx-get` | GET request | `hx-get="/api/data"` |
|
||
|
|
| `hx-post` | POST request | `hx-post="/api/submit"` |
|
||
|
|
| `hx-put` | PUT request | `hx-put="/api/update/1"` |
|
||
|
|
| `hx-delete` | DELETE request | `hx-delete="/api/item/1"` |
|
||
|
|
|
||
|
|
### Target & Swap Attributes
|
||
|
|
|
||
|
|
| Attribute | Description | Example |
|
||
|
|
|-----------|-------------|---------|
|
||
|
|
| `hx-target` | Element to update | `hx-target="#results"` |
|
||
|
|
| `hx-swap` | How to swap content | `hx-swap="innerHTML"` |
|
||
|
|
| `hx-swap-oob` | Out-of-band swap | `hx-swap-oob="outerHTML"` |
|
||
|
|
|
||
|
|
### Swap Values
|
||
|
|
|
||
|
|
| Value | Effect |
|
||
|
|
|-------|--------|
|
||
|
|
| `innerHTML` | Replace inner HTML (default) |
|
||
|
|
| `outerHTML` | Replace entire element |
|
||
|
|
| `beforebegin` | Insert before element |
|
||
|
|
| `afterbegin` | Insert at start of element |
|
||
|
|
| `beforeend` | Insert at end of element |
|
||
|
|
| `afterend` | Insert after element |
|
||
|
|
| `none` | Don't swap (use for side effects only) |
|
||
|
|
|
||
|
|
### Trigger Attributes
|
||
|
|
|
||
|
|
| Attribute | Description | Example |
|
||
|
|
|-----------|-------------|---------|
|
||
|
|
| `hx-trigger` | Event to trigger request | `hx-trigger="click"` |
|
||
|
|
| `hx-indicator` | Loading indicator | `hx-indicator="#spinner"` |
|
||
|
|
| `hx-confirm` | Confirmation dialog | `hx-confirm="Are you sure?"` |
|
||
|
|
|
||
|
|
### Special Triggers
|
||
|
|
|
||
|
|
```html
|
||
|
|
<!-- Trigger on input with 500ms debounce -->
|
||
|
|
hx-trigger="input changed delay:500ms"
|
||
|
|
|
||
|
|
<!-- Trigger on scroll into view -->
|
||
|
|
hx-trigger="revealed"
|
||
|
|
|
||
|
|
<!-- Trigger on page load -->
|
||
|
|
hx-trigger="load"
|
||
|
|
|
||
|
|
<!-- Trigger on intersection observer -->
|
||
|
|
hx-trigger="intersect"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Best Practices Learned
|
||
|
|
|
||
|
|
### 1. Use OOB for Multi-Element Updates
|
||
|
|
|
||
|
|
Instead of multiple requests, use OOB swaps:
|
||
|
|
|
||
|
|
```html
|
||
|
|
<!-- Server returns multiple elements in one response -->
|
||
|
|
<div id="main-content">Updated main</div>
|
||
|
|
<div id="sidebar" hx-swap-oob="outerHTML">Updated sidebar</div>
|
||
|
|
<div id="notification" hx-swap-oob="innerHTML">New notification</div>
|
||
|
|
```
|
||
|
|
|
||
|
|
### 2. Use `hx-swap="none"` for Side Effects
|
||
|
|
|
||
|
|
When you only need server-side effects (cookies, database), skip the swap:
|
||
|
|
|
||
|
|
```html
|
||
|
|
<button hx-post="/api/favorite"
|
||
|
|
hx-swap="none"
|
||
|
|
_="on htmx:afterRequest toggle .favorited on me">
|
||
|
|
```
|
||
|
|
|
||
|
|
### 3. Combine HTMX + Hyperscript
|
||
|
|
|
||
|
|
HTMX handles server communication; hyperscript handles local UI:
|
||
|
|
|
||
|
|
```html
|
||
|
|
<button hx-post="/toggle/theme"
|
||
|
|
hx-swap="none"
|
||
|
|
_="on htmx:afterRequest
|
||
|
|
toggle .dark-theme .light-theme on body">
|
||
|
|
```
|
||
|
|
|
||
|
|
### 4. Use CSS for Loading States
|
||
|
|
|
||
|
|
Instead of JavaScript spinners:
|
||
|
|
|
||
|
|
```html
|
||
|
|
<button hx-indicator=".spinner">
|
||
|
|
Submit <span class="spinner htmx-indicator">...</span>
|
||
|
|
</button>
|
||
|
|
```
|
||
|
|
|
||
|
|
```css
|
||
|
|
.htmx-indicator { display: none; }
|
||
|
|
.htmx-request .htmx-indicator { display: inline; }
|
||
|
|
```
|
||
|
|
|
||
|
|
### 5. Return Proper HTTP Status Codes
|
||
|
|
|
||
|
|
- `200 OK` - Success with content
|
||
|
|
- `204 No Content` - Success, no body needed
|
||
|
|
- `422 Unprocessable Entity` - Validation errors
|
||
|
|
- HTMX handles these gracefully
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Further Reading
|
||
|
|
|
||
|
|
- [HTMX Documentation](https://htmx.org/docs/)
|
||
|
|
- [Hyperscript Documentation](https://hyperscript.org/docs/)
|
||
|
|
- [HTMX Examples](https://htmx.org/examples/)
|
||
|
|
- Project doc: `doc/2-MODERN-WEB-TECHNIQUES.md` - Full techniques reference
|
||
|
|
- Project doc: `doc/4-HYPERSCRIPT-RULES.md` - Hyperscript patterns
|