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.

Live demo β†—

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 QUERY on every change (debounced) and on the search event that clears the field;
  • the handler reads the body itself β€” net/http parses form bodies only for POST, PUT, and PATCH;
  • 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 CSS appear animation, where an innerHTML swap would flash every row on every keystroke;
  • the page's htmx-config adds open to morphIgnore, so the <details> a visitor expanded stay open: a morph syncs attributes from the response, and the server never sends open.
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)
	}
}

All examples