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:
@@ -413,9 +413,10 @@ func (s *Server) fragmentNotFound(w http.ResponseWriter, msg string) {
|
||||
_, _ = io.WriteString(w, `<div class="stage-empty"><p class="stage-empty-title">`+msg+`</p><p class="stage-hint">The <a href="/evidence/">evidence index</a> lists every fragment in this build.</p></div>`)
|
||||
}
|
||||
|
||||
// handleSearch runs a keyword search. With HTMX it returns just the results
|
||||
// block; without JavaScript the same handler returns the whole page, so the form
|
||||
// works in both cases.
|
||||
// handleSearch runs a keyword search. With HTMX it returns just the contents of
|
||||
// the results block, which the search page's own form swaps into #search-results;
|
||||
// without JavaScript the same handler returns the whole page, so the form works
|
||||
// in both cases.
|
||||
func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
|
||||
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||
failed := false
|
||||
@@ -451,7 +452,9 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
|
||||
Render(context.Context, io.Writer) error
|
||||
} = templates.SearchPage(view)
|
||||
if r.Header.Get("HX-Request") == "true" {
|
||||
c = templates.SearchResults(view)
|
||||
// The element that holds #search-results stays where it is, so the
|
||||
// fragment carries the contents and not the wrapper.
|
||||
c = templates.SearchResultsBody(view)
|
||||
}
|
||||
if err := c.Render(r.Context(), &buf); err != nil {
|
||||
s.log.Error("render search", "error", err)
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -274,8 +275,14 @@ func TestSearchWorksWithAndWithoutHTMX(t *testing.T) {
|
||||
if strings.Contains(frag, "<html") {
|
||||
t.Errorf("htmx search %q returned a whole page, want the results block", tc.q)
|
||||
}
|
||||
if !strings.Contains(frag, `id="search-results"`) {
|
||||
t.Errorf("htmx search %q did not return #search-results", tc.q)
|
||||
// The fragment is swapped into #search-results with innerHTML, so it
|
||||
// must not carry the wrapper again: a second element with the same id
|
||||
// inside the first is invalid and makes the live region announce twice.
|
||||
if strings.Contains(frag, `id="search-results"`) {
|
||||
t.Errorf("htmx search %q re-declares #search-results inside itself", tc.q)
|
||||
}
|
||||
if !strings.Contains(frag, "results for") {
|
||||
t.Errorf("htmx search %q returned no results", tc.q)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -369,3 +376,190 @@ func TestFirstByteBudget(t *testing.T) {
|
||||
t.Errorf("first byte in %s, budget is 100ms", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
// navSearchForm isolates the navigation search form's opening tag and markup.
|
||||
// The layout has one such form on every page; there is nothing to search for if
|
||||
// it is missing.
|
||||
func navSearchForm(t *testing.T, path, body string) string {
|
||||
t.Helper()
|
||||
marker := strings.Index(body, `class="search nav-search"`)
|
||||
if marker < 0 {
|
||||
t.Fatalf("%s: no navigation search form", path)
|
||||
}
|
||||
start := strings.LastIndex(body[:marker], "<form")
|
||||
end := strings.Index(body[marker:], "</form>")
|
||||
if start < 0 || end < 0 {
|
||||
t.Fatalf("%s: could not isolate the navigation search form", path)
|
||||
}
|
||||
return body[start : marker+end]
|
||||
}
|
||||
|
||||
// TestNavigationSearchNavigatesFromEveryPage is the AUD-151 regression: the nav
|
||||
// form used to carry hx-target="#search-results", which only exists on /search,
|
||||
// so htmx silently discarded the response everywhere else. The nav form must be
|
||||
// a plain GET, and no page may target a fragment it does not carry.
|
||||
func TestNavigationSearchNavigatesFromEveryPage(t *testing.T) {
|
||||
ts := newServer(t)
|
||||
pages := []string{
|
||||
"/",
|
||||
"/ai/", "/platform/", "/edge/", "/products/", "/history/", "/about/",
|
||||
"/evidence/", "/edge/edge-fleet/", "/404.html",
|
||||
}
|
||||
for _, path := range pages {
|
||||
code, _, body := get(t, ts, path, nil)
|
||||
if code != http.StatusOK {
|
||||
t.Errorf("%s: status %d", path, code)
|
||||
continue
|
||||
}
|
||||
form := navSearchForm(t, path, body)
|
||||
if strings.Contains(form, "hx-") {
|
||||
t.Errorf("%s: navigation search form carries an htmx attribute:\n%s", path, form)
|
||||
}
|
||||
if !strings.Contains(form, `action="/search"`) {
|
||||
t.Errorf("%s: navigation search form does not post to /search:\n%s", path, form)
|
||||
}
|
||||
if !strings.Contains(form, `method="get"`) {
|
||||
t.Errorf("%s: navigation search form is not a GET:\n%s", path, form)
|
||||
}
|
||||
if strings.Contains(body, `hx-target="#search-results"`) {
|
||||
t.Errorf("%s: page targets #search-results, which only the search page carries", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSearchFromAnyPageLandsOnResults follows the nav form the way a browser with
|
||||
// JavaScript disabled would: GET the form action with q set, expect a page.
|
||||
func TestSearchFromAnyPageLandsOnResults(t *testing.T) {
|
||||
ts := newServer(t)
|
||||
for _, path := range []string{"/", "/edge/", "/platform/"} {
|
||||
code, _, body := get(t, ts, path, nil)
|
||||
if code != http.StatusOK {
|
||||
t.Errorf("%s: status %d", path, code)
|
||||
continue
|
||||
}
|
||||
form := navSearchForm(t, path, body)
|
||||
if !strings.Contains(form, `action="/search"`) {
|
||||
t.Errorf("%s: nav form has no /search action to follow", path)
|
||||
continue
|
||||
}
|
||||
// No HX-Request header: this is the no-JavaScript path.
|
||||
code, _, landed := get(t, ts, "/search?q=zigbee", nil)
|
||||
if code != http.StatusOK {
|
||||
t.Errorf("search from %s: status %d", path, code)
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(landed), "<!doctype html>") {
|
||||
t.Errorf("search from %s did not land on a page", path)
|
||||
}
|
||||
if !strings.Contains(landed, "results for") || !strings.Contains(landed, "/edge/") {
|
||||
t.Errorf("search from %s landed on a page with no results", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestNoHTMXTargetIsMissingFromItsPage walks the whole built site: every
|
||||
// hx-target selector must name an element in the same document. This is the
|
||||
// general form of the AUD-151 bug, so the next one fails a test instead of
|
||||
// failing silently in a browser.
|
||||
func TestNoHTMXTargetIsMissingFromItsPage(t *testing.T) {
|
||||
target := regexp.MustCompile(`hx-target="([^"]+)"`)
|
||||
files := 0
|
||||
err := filepath.WalkDir(distDir, func(p string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() || !strings.HasSuffix(p, ".html") {
|
||||
return nil
|
||||
}
|
||||
raw, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
files++
|
||||
body := string(raw)
|
||||
for _, m := range target.FindAllStringSubmatch(body, -1) {
|
||||
selector := m[1]
|
||||
if !strings.HasPrefix(selector, "#") {
|
||||
continue // closest/find targets are resolved from the trigger
|
||||
}
|
||||
id := strings.TrimPrefix(selector, "#")
|
||||
if !strings.Contains(body, `id="`+id+`"`) {
|
||||
t.Errorf("%s: hx-target=%q has no matching element", p, selector)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("walk %s: %v", distDir, err)
|
||||
}
|
||||
if files == 0 {
|
||||
t.Fatal("no HTML files scanned; the build fixture is empty")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSearchEmptyQueryShowsAPrompt covers "an empty query shows a prompt, not an
|
||||
// empty list".
|
||||
func TestSearchEmptyQueryShowsAPrompt(t *testing.T) {
|
||||
ts := newServer(t)
|
||||
code, _, body := get(t, ts, "/search", nil)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("status = %d", code)
|
||||
}
|
||||
if !strings.Contains(body, "Type one or two words above") {
|
||||
t.Error("empty search shows no prompt")
|
||||
}
|
||||
if strings.Contains(body, `class="results"`) {
|
||||
t.Error("empty search renders a results list")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSearchWithNoMatchesSuggestsTheAreas covers "a query with no matches shows a
|
||||
// clear nothing-found message and suggests browsing the areas".
|
||||
func TestSearchWithNoMatchesSuggestsTheAreas(t *testing.T) {
|
||||
ts := newServer(t)
|
||||
code, _, body := get(t, ts, "/search?q=appaloosafoundation", nil)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("status = %d", code)
|
||||
}
|
||||
if !strings.Contains(body, "Nothing found for") {
|
||||
t.Error("no nothing-found message")
|
||||
}
|
||||
for _, href := range []string{`href="/ai/"`, `href="/platform/"`, `href="/edge/"`, `href="/evidence/"`} {
|
||||
if !strings.Contains(body, href) {
|
||||
t.Errorf("nothing-found message does not offer %s", href)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSearchCountIsAnnounced checks the count is inside the polite live region
|
||||
// and marked as a status, so a screen reader reads "4 results for …" rather than
|
||||
// an undifferentiated block of titles.
|
||||
func TestSearchCountIsAnnounced(t *testing.T) {
|
||||
ts := newServer(t)
|
||||
_, _, body := get(t, ts, "/search?q=zigbee", nil)
|
||||
if !strings.Contains(body, `id="search-results" aria-live="polite"`) {
|
||||
t.Error("results container is not a live region")
|
||||
}
|
||||
if !regexp.MustCompile(`<p class="search-count" role="status">\d+ results for`).MatchString(body) {
|
||||
t.Error("result count is not announced as a status inside the live region")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHTMXSearchStillReturnsTheFragment keeps the search page's own upgrade: with
|
||||
// the header set, the handler returns the block and not a whole page.
|
||||
func TestHTMXSearchStillReturnsTheFragment(t *testing.T) {
|
||||
ts := newServer(t)
|
||||
code, _, frag := get(t, ts, "/search?q=zigbee", map[string]string{"HX-Request": "true"})
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("status = %d", code)
|
||||
}
|
||||
if strings.Contains(frag, "<html") {
|
||||
t.Error("htmx search returned a whole page")
|
||||
}
|
||||
if strings.Contains(frag, `id="search-results"`) {
|
||||
t.Error("htmx search re-declares #search-results inside itself")
|
||||
}
|
||||
if !strings.Contains(frag, `role="status"`) || !strings.Contains(frag, "results for") {
|
||||
t.Error("htmx search did not return the results block")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user