Files
cv-site/doc/20-HTMX-LEARNING.md
T
juanatsap db642c7cc2 docs: Add HTML Invoker Commands and Lazy Loading sections
- Document HTML Invoker Commands API (commandfor/command)
- Document lazy loading pattern for web components
- Include CDN comparison (esm.sh ?bundle vs others)
- Add CSP configuration notes
- Performance metrics before/after
2025-12-02 08:33:18 +00:00

763 lines
21 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. [HTML Invoker Commands API](#html-invoker-commands-api)
8. [Lazy Loading Web Components](#lazy-loading-web-components)
9. [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"
```
---
## HTML Invoker Commands API
**Browser Support**: Chrome/Edge 135+, Firefox Nightly, Safari TP
### The Problem
Opening and closing `<dialog>` elements traditionally requires JavaScript:
```html
<!-- Old way - onclick handlers everywhere -->
<button onclick="document.getElementById('my-modal').showModal()">
Open Modal
</button>
<dialog id="my-modal">
<button onclick="document.getElementById('my-modal').close()">
Close
</button>
</dialog>
```
This is verbose, error-prone, and mixes behavior with markup.
### The Solution: `commandfor` + `command`
The new HTML Invoker Commands API provides declarative modal control:
```html
<!-- New way - pure HTML attributes -->
<button commandfor="my-modal" command="show-modal">
Open Modal
</button>
<dialog id="my-modal">
<button commandfor="my-modal" command="close">
Close
</button>
</dialog>
```
### Command Values
| Command | Effect | Target Element |
|---------|--------|----------------|
| `show-modal` | Opens dialog as modal | `<dialog>` |
| `close` | Closes dialog | `<dialog>` |
| `show-popover` | Shows popover | `[popover]` |
| `hide-popover` | Hides popover | `[popover]` |
| `toggle-popover` | Toggles popover | `[popover]` |
### Project Implementation
**Files**: `templates/partials/widgets/*.html`, `templates/partials/modals/*.html`
```html
<!-- Info button opens info modal -->
<button id="info-button"
commandfor="info-modal"
command="show-modal"
aria-label="Show information">
<iconify-icon icon="mdi:information-outline"></iconify-icon>
</button>
<!-- Modal with close button -->
<dialog id="info-modal" class="info-modal">
<div class="info-modal-content">
<button class="info-modal-close"
commandfor="info-modal"
command="close"
aria-label="Close">
<iconify-icon icon="mdi:close"></iconify-icon>
</button>
<!-- Modal content -->
</div>
</dialog>
```
### Benefits
1. **No JavaScript** - Pure HTML declarative syntax
2. **Accessibility** - Built-in keyboard and screen reader support
3. **Reduced Errors** - No typos in element IDs within JavaScript
4. **Cleaner Templates** - Removes onclick clutter
5. **Progressive Enhancement** - Graceful degradation in older browsers
### Fallback for Older Browsers
If you need to support browsers without Invoker Commands:
```html
<button commandfor="my-modal"
command="show-modal"
onclick="this.commandfor || document.getElementById('my-modal').showModal()">
Open Modal
</button>
```
---
## Lazy Loading Web Components
### The Problem
Heavy web components (like ninja-keys command palette) add significant initial load time even when users may never use them:
```
Initial Load: 81 module requests, ~300KB, 2+ seconds
```
### The Solution: Dynamic Import on Demand
Only load the component when the user actually needs it:
```javascript
// Don't import at top of file
// import 'ninja-keys'; // ❌ Loads immediately
// Instead, lazy load on first use
let loaded = false;
async function loadNinjaKeys() {
if (loaded) return;
// Dynamic import - only fetches when called
await import('https://esm.sh/ninja-keys@1.2.2?bundle');
// Create element after module loads
const container = document.getElementById('cmd-k-container');
const ninjaKeys = document.createElement('ninja-keys');
ninjaKeys.id = 'cmd-k-bar';
container.appendChild(ninjaKeys);
loaded = true;
}
// Trigger on user action
document.addEventListener('keydown', (e) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
loadNinjaKeys();
}
});
```
### Project Implementation
**File**: `templates/partials/layout/body-scripts.html`
```html
<!-- Placeholder container (always present, empty) -->
<div id="cmd-k-container"></div>
<script>
(function() {
let ninjaLoaded = false;
let ninjaLoading = false;
async function loadNinjaKeys() {
if (ninjaLoaded || ninjaLoading) return;
ninjaLoading = true;
// Use esm.sh with ?bundle for single-file delivery
await import('https://esm.sh/ninja-keys@1.2.2?bundle');
// Create element
const container = document.getElementById('cmd-k-container');
const ninjaKeys = document.createElement('ninja-keys');
ninjaKeys.id = 'cmd-k-bar';
ninjaKeys.placeholder = 'Type a command or search...';
ninjaKeys.hideBreadcrumbs = true;
container.appendChild(ninjaKeys);
// Load initialization script
const script = document.createElement('script');
script.src = '/static/js/ninja-keys-init.js';
document.body.appendChild(script);
ninjaLoaded = true;
ninjaLoading = false;
// Open after brief initialization delay
setTimeout(() => ninjaKeys.open(), 100);
}
function openNinjaKeys() {
const nk = document.getElementById('cmd-k-bar');
if (nk && typeof nk.open === 'function') {
nk.open();
}
}
// CMD+K / Ctrl+K keyboard shortcut
document.addEventListener('keydown', (e) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
ninjaLoaded ? openNinjaKeys() : loadNinjaKeys();
}
});
// Button click trigger
document.addEventListener('click', (e) => {
if (e.target.closest('#cmd-k-button, .cmd-k-trigger')) {
e.preventDefault();
ninjaLoaded ? openNinjaKeys() : loadNinjaKeys();
}
});
})();
</script>
```
### CDN Choice: esm.sh with ?bundle
| CDN | Requests | Why |
|-----|----------|-----|
| unpkg.com | 80+ (redirect chains) | ❌ Follows all peer deps |
| esm.sh | 80+ (without bundle) | ❌ Resolves all imports |
| esm.sh?bundle | 2-3 | ✅ Pre-bundled single file |
| jsdelivr | 1 | ✅ Also good option |
```javascript
// ❌ Triggers 80+ module requests
await import('https://esm.sh/ninja-keys@1.2.2');
// ✅ Single bundled file
await import('https://esm.sh/ninja-keys@1.2.2?bundle');
```
### CSP Configuration
Remember to add your CDN to Content Security Policy:
```go
// internal/middleware/security.go
csp := "default-src 'self'; " +
"script-src 'self' 'unsafe-inline' https://esm.sh https://cdn.jsdelivr.net; " +
// ...
```
### Performance Results
| Metric | Before | After |
|--------|--------|-------|
| Initial requests | 81 | 0 |
| Initial load time | +2.1s | 0ms |
| On CMD+K | 0 | 3 requests |
| Subsequent uses | 0 | 0 (cached) |
### Best Practices
1. **Use Placeholder Containers** - Empty div ready for component injection
2. **Prevent Double Loading** - Track loading state with flags
3. **Bundle Dependencies** - Use `?bundle` parameter on esm.sh
4. **Cache First Load** - Browser caches subsequent uses automatically
5. **Multiple Triggers** - Support keyboard AND button triggers
6. **Initialization Delay** - Wait briefly after element creation for setup
---
## 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