htmx 4: QUERY and morph swaps
The bound QUERY verb searches as you type, and innerMorph keeps the entries you expanded while the list changes.
htmx 4: QUERY and morph swaps
htmx 4 adds the QUERY method β safe and idempotent like GET, with
the parameters in the body like POST β as hx-query, and ghtmx binds
it like the other five verbs: hx-query={ Search } resolves against
the QUERY /search registration at build time. This example pins
4.0.0 in its own ghtmx.json and pairs the new verb with the new
swap style:
- the search box issues a
QUERYon every change (debounced) and on thesearchevent that clears the field; - the handler reads the body itself β
net/httpparses form bodies only forPOST,PUT, andPATCH; - the result list is swapped with
hx-swap="innerMorph", so entries with stable ids keep their DOM nodes across responses β only rows that actually appear run the CSSappearanimation, where aninnerHTMLswap would flash every row on every keystroke; - the page's
htmx-configaddsopentomorphIgnore, so the<details>a visitor expanded stay open: a morph syncs attributes from the response, and the server never sendsopen.
ghtmx generate && go run ./cmd # serves http://127.0.0.1:8088/search
Under a 2.0.10 pin both hx-query and innerMorph are reported as
introduced in 4.0.0 (GHTMX-E0501), and ghtmx routes lists the
QUERY registration beside the others.
query.ghtmx
package htmx4query
import "github.com/go-monolith/ghtmx/examples/htmx4-query/ghtmxgen"
templ page(all []Package) {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>htmx 4: QUERY and morph swaps</title>
<meta name="htmx-config" content='{"morphIgnore": ["data-htmx-powered", "open"]}'/>
@ghtmxgen.HTMXScript()
@styleSheet()
</head>
<body>
<main class="app">
<span class="badge">ghtmx example Β· htmx 4</span>
<h1>QUERY and morph swaps</h1>
<p class="tagline">Typing issues a <code>QUERY</code> request β safe like GET, parameters in the body like POST β bound to the <code>QUERY /search</code> route. The list is swapped with <code>innerMorph</code>: entries that survive a keystroke are updated in place, not recreated β only newcomers fade in, and the ones you expanded stay expanded.</p>
<input type="search" name="q" placeholder="Search the standard libraryβ¦" autocomplete="off" hx-query={ Search } hx-trigger="input changed delay:300ms, search" hx-target="#results" hx-swap="innerMorph"/>
<div id="results">
@results(all)
</div>
</main>
</body>
</html>
}
// results is the list the QUERY response morphs into #results. Stable
// ids let the morph match entries across responses.
fragment results(pkgs []Package) {
if len(pkgs) == 0 {
<p class="muted">no packages match</p>
} else {
<ul class="results">
for _, p := range pkgs {
<li id={ "pkg-" + p.Path }>
<details>
<summary><code>{ p.Path }</code></summary>
<p>{ p.Doc }</p>
</details>
</li>
}
</ul>
}
}
ghtmx.json
{
"htmxVersion": "4.0.0",
"generatedPackage": { "dir": "examples/htmx4-query/ghtmxgen" }
}
query.go
// The htmx4-query example: htmx 4 adds the QUERY method β safe and
// idempotent like GET, with the parameters in the body like POST β and
// morph swaps that update a region in place instead of replacing it. A
// search box issues QUERY requests as you type (hx-query, the sixth
// route-bindable verb) and morphs the result list: entries present in
// both responses keep their DOM nodes (only newcomers run the appear
// animation), and with "open" in htmx.config.morphIgnore the <details>
// a visitor expanded stay expanded while the list around them changes.
// The compiler binds hx-query against the "QUERY /search" registration
// and validates innerMorph against the 4.0.0 pin in ghtmx.json.
//
// Run it with:
//
// ghtmx generate && go run ./cmd
package htmx4query
import (
_ "embed"
"errors"
"io"
"net/http"
"net/url"
"strings"
"github.com/go-monolith/ghtmx"
)
//go:embed query.css
var styleCSS string
// styleSheet inlines query.css into the page head. The rules live in
// their own file so the template shows markup, not presentation.
func styleSheet() ghtmx.Component {
return ghtmx.Raw("<style>" + styleCSS + "</style>")
}
// Package is one searchable entry: a Go standard-library package.
type Package struct {
Path string
Doc string
}
// stdlib is the static data the search runs over.
var stdlib = []Package{
{"bufio", "Buffered I/O: wraps io.Reader and io.Writer with buffering and line scanning."},
{"bytes", "Functions for manipulating byte slices, mirroring the strings package."},
{"context", "Deadlines, cancellation signals, and request-scoped values across API boundaries."},
{"encoding/json", "Encoding and decoding of JSON as defined in RFC 7159."},
{"errors", "Functions to manipulate errors: New, Is, As, Unwrap, and Join."},
{"fmt", "Formatted I/O with functions analogous to C's printf and scanf."},
{"io", "Basic interfaces to I/O primitives: Reader, Writer, and their combinators."},
{"net/http", "HTTP client and server implementations."},
{"os", "A platform-independent interface to operating system functionality."},
{"sort", "Primitives for sorting slices and user-defined collections."},
{"strings", "Functions for manipulating UTF-8 encoded strings."},
{"sync", "Basic synchronization primitives such as mutual exclusion locks."},
{"testing", "Support for automated testing of Go packages."},
{"time", "Functionality for measuring and displaying time."},
}
// matches returns the packages whose path or doc contains q, all of
// them when q is empty.
func matches(q string) []Package {
q = strings.ToLower(strings.TrimSpace(q))
if q == "" {
return stdlib
}
var out []Package
for _, p := range stdlib {
if strings.Contains(p.Path, q) || strings.Contains(strings.ToLower(p.Doc), q) {
out = append(out, p)
}
}
return out
}
func searchHome(w http.ResponseWriter, r *http.Request) {
if err := page(stdlib).Render(r.Context(), w); err != nil {
http.Error(w, "failed to render", http.StatusInternalServerError)
}
}
// Search answers a QUERY request. Its parameters travel in the body,
// form-encoded like a POST, but net/http parses bodies only for POST,
// PUT, and PATCH β so the handler reads and parses the body itself.
func Search(w http.ResponseWriter, r *http.Request) {
raw, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 4096))
if err != nil {
var tooBig *http.MaxBytesError
if errors.As(err, &tooBig) {
http.Error(w, "body too large", http.StatusRequestEntityTooLarge)
return
}
http.Error(w, "bad body", http.StatusBadRequest)
return
}
params, err := url.ParseQuery(string(raw))
if err != nil {
http.Error(w, "bad query", http.StatusBadRequest)
return
}
if err := resultsFragment(matches(params.Get("q"))).RenderFragment(r.Context(), w); err != nil {
http.Error(w, "failed to render", http.StatusInternalServerError)
}
}
// Routes builds the example's router; the official docs site mounts
// it as a live demo.
func Routes() *http.ServeMux {
mux := http.NewServeMux()
mux.HandleFunc("GET /search", searchHome)
mux.HandleFunc("QUERY /search", Search)
return mux
}
query.css
:root { --primary: #008391; --bg: #f5f6f7; --surface: #ffffff; --text: #1c1e21; --muted: #525860; --border: #dadde1; color-scheme: light dark; }
@media (prefers-color-scheme: dark) {
:root { --primary: #dbbc30; --bg: #1b1b1d; --surface: #242526; --text: #e3e3e3; --muted: #a5adba; --border: #444950; }
}
* { box-sizing: border-box; }
body {
font-family: system-ui, sans-serif; margin: 0; min-height: 100vh;
background: var(--bg); color: var(--text);
display: flex; justify-content: center; align-items: flex-start;
}
.app {
width: min(40rem, 92vw); margin: 3rem 0; padding: 1.6rem 1.8rem;
background: var(--surface); border: 1px solid var(--border);
border-radius: .6rem; box-shadow: 0 8px 24px rgba(0, 0, 0, .1);
}
.badge {
display: inline-block; font-size: .72rem; font-weight: 600;
color: var(--primary); border: 1px solid var(--primary);
border-radius: 999px; padding: .1rem .6rem; margin-bottom: .8rem;
}
h1 { margin: 0 0 .4rem; font-size: 1.5rem; }
.tagline { margin: 0 0 1.2rem; color: var(--muted); font-size: .95rem; }
input[type="search"] {
width: 100%; font-size: 1rem; padding: .55rem .8rem; border-radius: .45rem;
border: 1px solid var(--border); background: var(--bg); color: inherit;
}
input[type="search"]:focus { outline: 2px solid var(--primary); outline-offset: 1px; }
.results { list-style: none; margin: 1rem 0 0; padding: 0; }
.results li { border-bottom: 1px solid var(--border); animation: appear .6s ease-out; }
@keyframes appear { from { background: color-mix(in srgb, var(--primary) 25%, transparent); } to { background: transparent; } }
.results li:last-child { border-bottom: none; }
summary { cursor: pointer; padding: .5rem .2rem; }
summary code { font-size: .95rem; }
details[open] summary { color: var(--primary); }
details p { margin: 0 0 .7rem 1.2rem; color: var(--muted); font-size: .9rem; }
.muted { color: var(--muted); margin: 1rem 0 0; }
cmd/main.go
// Command htmx4query serves the htmx4-query example standalone.
package main
import (
"fmt"
"net/http"
"os"
example "github.com/go-monolith/ghtmx/examples/htmx4-query"
)
func main() {
addr := "127.0.0.1:8088"
if v := os.Getenv("GHTMX_EXAMPLE_ADDR"); v != "" {
addr = v
}
fmt.Printf("Listening on http://%s/search\n", addr)
if err := http.ListenAndServe(addr, example.Routes()); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}