search: make the navigation form work from every page (AUD-151)

The nav form carried hx-get="/search" hx-target="#search-results", but
#search-results exists only on /search. On every other page htmx found no
target and silently discarded the response: a search box that looks like it
works and does nothing.

- SearchForm is now a plain GET with no htmx attributes (navigation and the
  landing page), so it navigates to /search?q=… from any page and with
  JavaScript disabled.
- SearchFormLive keeps the in-place swap on the search page, where the target
  does exist. For HX-Request the server now returns the block's *contents*
  (SearchResultsBody), so the fragment no longer nests a second element with
  id="search-results" inside the live region.
- Empty query shows a prompt instead of an empty list. A zero-match query says
  "Nothing found" and offers the six areas plus the evidence index.
- The result count is role="status" inside the existing aria-live container, so
  a screen reader reads "4 results for …" rather than a run of titles.

Tests (internal/server, internal/site):
- the nav form carries no htmx attribute on nine pages, and no built page
  carries an hx-target whose element is missing (walks all of dist/);
- search submitted from /, /edge/ and /platform/ lands on a page with results
  with no HX-Request header, i.e. the JavaScript-disabled path;
- empty query, zero match and the announced count;
- a content change followed by a build is visible in dist/search.db. That is
  the deploy path: deploy/deploy.sh runs `make dist` before it stages a
  release, and search.Build removes and rewrites the index every build.

DoD: search from home, /edge and /platform lands on results; works with
JavaScript disabled; zero-match query shows a message; content change followed
by a deploy is reflected in search; no htmx request targets a missing element.

(cherry picked from commit a56c795768e1e9b10204aae04c3612d7a96775a7)
This commit is contained in:
2026-10-28 15:45:54 +11:00
parent 286aa12bb0
commit d793ab0871
8 changed files with 400 additions and 36 deletions

View File

@@ -101,17 +101,43 @@ templ Breadcrumbs(crumbs []Crumb) {
}
}
// SearchForm is a GET form: it works with JavaScript disabled and is upgraded by
// HTMX when scripts are available.
// SearchForm is the plain GET form used in the navigation and the landing page.
// It navigates to /search and carries no htmx attributes, because #search-results
// only exists on the search page: an hx-target that is not in the document makes
// htmx discard the response, which is a form that looks like it works and does
// nothing. A plain GET works from every page and with JavaScript disabled.
templ SearchForm(q string, class string) {
<form class={ "search", class } action="/search" method="get" role="search"
hx-get="/search" hx-target="#search-results" hx-swap="innerHTML" hx-push-url="true">
<label class="visually-hidden" for={ "q-" + class }>Search the site</label>
<input id={ "q-" + class } type="search" name="q" value={ q } placeholder="Search — try “zigbee”, “signage”, “routing”" autocomplete="off"/>
<form class={ "search", class } action="/search" method="get" role="search">
@searchField(q, class)
<button type="submit">Search</button>
</form>
}
// SearchFormLive is the search page's own form. HTMX swaps the results block in
// place, which is safe here and only here: #search-results is part of the page.
templ SearchFormLive(q string, class string) {
<form
class={ "search", class }
action="/search"
method="get"
role="search"
hx-get="/search"
hx-target="#search-results"
hx-swap="innerHTML"
hx-push-url="true"
>
@searchField(q, class)
<button type="submit">Search</button>
</form>
}
// searchField is the label and input both forms share. The id is derived from the
// form class, so two forms on one page never collide.
templ searchField(q string, class string) {
<label class="visually-hidden" for={ "q-" + class }>Search the site</label>
<input id={ "q-" + class } type="search" name="q" value={ q } placeholder="Search — try “zigbee”, “signage”, “routing”" autocomplete="off"/>
}
// Stage is the presentation panel. Everything about how it is fed comes from
// the binding: route mode swaps in pre-rendered fragments, stream mode listens
// on an event stream. The markup is identical either way.