feat: lazy load ninja-keys + HTML Invoker Commands API
- Lazy load ninja-keys only on CMD+K press (0 requests on initial load) - Use esm.sh bundled module (3 requests vs ~81 previously) - Add esm.sh to CSP whitelist - Implement HTML Invoker Commands API for modals: - commandfor="modal-id" + command="show-modal" for opening - commandfor="modal-id" + command="close" for closing - Removes need for onclick handlers on modal buttons - Refactor index.html into layout partials (head, body-scripts) - Add comprehensive tests for both features
This commit is contained in:
@@ -0,0 +1,502 @@
|
||||
# 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
|
||||
Reference in New Issue
Block a user