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:
@@ -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.
|
||||
|
||||
@@ -135,40 +135,67 @@ templ SearchPage(v SearchView) {
|
||||
<h1>Search { strconv.Itoa(v.Indexed) } pages</h1>
|
||||
<p class="doc-claim">Full-text search over page titles and body text. Served from a local index — no model, no network call.</p>
|
||||
</header>
|
||||
@SearchForm(v.Query, "page-search")
|
||||
@SearchFormLive(v.Query, "page-search")
|
||||
@SearchResults(v)
|
||||
</article>
|
||||
}
|
||||
}
|
||||
|
||||
// SearchResults is swapped into #search-results by HTMX.
|
||||
// SearchResults is the results block as a page element: the live region wrapper
|
||||
// plus its contents, which is what the whole page renders. Without JavaScript the
|
||||
// form reloads the page and this markup arrives in the document.
|
||||
templ SearchResults(v SearchView) {
|
||||
<div id="search-results" aria-live="polite">
|
||||
if v.Query == "" {
|
||||
<p class="section-note">Try “zigbee”, “signage”, “routing”, “backups” or “guardrails”.</p>
|
||||
} else if v.Failed {
|
||||
<p class="search-empty">That query could not be run. Try fewer or simpler words.</p>
|
||||
} else if len(v.Results) == 0 {
|
||||
<p class="search-empty">No page matches <q>{ v.Query }</q>. The <a href="/evidence/">evidence index</a> lists everything by area.</p>
|
||||
} else {
|
||||
<p class="search-count">{ strconv.Itoa(len(v.Results)) } results for <q>{ v.Query }</q></p>
|
||||
<ol class="results">
|
||||
for _, r := range v.Results {
|
||||
<li class="result">
|
||||
<h2><a href={ templ.SafeURL(r.URL) }>{ r.Title }</a></h2>
|
||||
<p class="result-meta">
|
||||
<span class="kind">{ r.Area }</span>
|
||||
<span class="kind">L{ strconv.Itoa(r.Level) }</span>
|
||||
<span class={ ConfidenceClass(r.Confidence) }>{ r.Confidence }</span>
|
||||
</p>
|
||||
<p class="result-snippet">@SafeSnippet(r.Snippet)</p>
|
||||
</li>
|
||||
}
|
||||
</ol>
|
||||
}
|
||||
@SearchResultsBody(v)
|
||||
</div>
|
||||
}
|
||||
|
||||
// SearchResultsBody is the results block without the live region. This is what an
|
||||
// htmx request returns: the search page's form swaps it into #search-results with
|
||||
// innerHTML, so returning the wrapper too would nest a second element with the
|
||||
// same id inside the first and make the live region announce twice.
|
||||
//
|
||||
// Everything here arrives inside the polite live region, and the count carries
|
||||
// role="status" on top of that, so the number is read as one utterance instead of
|
||||
// a run of result titles.
|
||||
templ SearchResultsBody(v SearchView) {
|
||||
if v.Query == "" {
|
||||
<p class="search-empty">Type one or two words above and press Enter. Good starting points: “zigbee”, “signage”, “routing”, “backups”, “guardrails”.</p>
|
||||
} else if v.Failed {
|
||||
<p class="search-empty">That query could not be run. Try fewer or simpler words.</p>
|
||||
} else if len(v.Results) == 0 {
|
||||
<p class="search-empty">Nothing found for <q>{ v.Query }</q>. Try a shorter or more general word, or browse an area:</p>
|
||||
@searchAreaLinks(v.Areas)
|
||||
} else {
|
||||
<p class="search-count" role="status">{ strconv.Itoa(len(v.Results)) } results for <q>{ v.Query }</q></p>
|
||||
<ol class="results">
|
||||
for _, r := range v.Results {
|
||||
<li class="result">
|
||||
<h2><a href={ templ.SafeURL(r.URL) }>{ r.Title }</a></h2>
|
||||
<p class="result-meta">
|
||||
<span class="kind">{ r.Area }</span>
|
||||
<span class="kind">L{ strconv.Itoa(r.Level) }</span>
|
||||
<span class={ ConfidenceClass(r.Confidence) }>{ r.Confidence }</span>
|
||||
</p>
|
||||
<p class="result-snippet">@SafeSnippet(r.Snippet)
|
||||
</p>
|
||||
</li>
|
||||
}
|
||||
</ol>
|
||||
}
|
||||
}
|
||||
|
||||
// searchAreaLinks is the way out of an empty result: the six areas plus the
|
||||
// evidence index, so a failed search never leaves the reader at a dead end.
|
||||
templ searchAreaLinks(areas []LinkView) {
|
||||
<ul class="search-areas">
|
||||
for _, a := range areas {
|
||||
<li><a href={ templ.SafeURL(a.Href) }>{ a.Label }</a></li>
|
||||
}
|
||||
<li><a href="/evidence/">Evidence index</a></li>
|
||||
</ul>
|
||||
}
|
||||
|
||||
// StreamPage proves the panel can be re-pointed at a stream. The layout and the
|
||||
// panel markup are the ones every other page uses; only the binding differs.
|
||||
templ StreamPage(v StreamView) {
|
||||
|
||||
@@ -151,6 +151,9 @@ type (
|
||||
Total int
|
||||
Indexed int
|
||||
Failed bool
|
||||
// Areas are the six area landings, offered as the way out of an
|
||||
// empty result. Nothing here was ever a search hit.
|
||||
Areas []LinkView
|
||||
}
|
||||
|
||||
// StreamView is the SSE-driven stage demonstration page.
|
||||
|
||||
Reference in New Issue
Block a user