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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +66,16 @@ func (Shell) Nav(area string) []templates.NavItem {
|
||||
return items
|
||||
}
|
||||
|
||||
// AreaLinks is the six areas as plain links. The search page offers them when a
|
||||
// query matches nothing, so an empty result is still a way into the site.
|
||||
func (Shell) AreaLinks() []templates.LinkView {
|
||||
out := make([]templates.LinkView, 0, len(content.Areas))
|
||||
for _, a := range content.Areas {
|
||||
out = append(out, templates.LinkView{Label: a.Title, Href: "/" + a.Key + "/"})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// RouteBinding is the Mode A driver: one pre-rendered fragment per click.
|
||||
func (Shell) RouteBinding() stage.Binding {
|
||||
return stage.RouteDriver{BaseURL: "/stage"}.Bind()
|
||||
@@ -114,6 +124,7 @@ func (sh Shell) SearchView(indexed int, q string, results []search.Result, faile
|
||||
Results: results,
|
||||
Indexed: indexed,
|
||||
Failed: failed,
|
||||
Areas: sh.AreaLinks(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"gitea.lab.audasmedia.com.au/sam/ai-resume/internal/content"
|
||||
"gitea.lab.audasmedia.com.au/sam/ai-resume/internal/search"
|
||||
)
|
||||
|
||||
// newSite loads the repository's own content. If this fails, every other test
|
||||
@@ -172,3 +173,93 @@ func TestBuildWritesEveryPublishedPage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// copyTree copies a directory tree. The freshness test needs its own content
|
||||
// tree so it can change a page without touching the repository.
|
||||
func copyTree(src, dst string) error {
|
||||
return filepath.WalkDir(src, func(p string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel, err := filepath.Rel(src, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target := filepath.Join(dst, rel)
|
||||
if d.IsDir() {
|
||||
return os.MkdirAll(target, 0o755)
|
||||
}
|
||||
raw, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(target, raw, 0o644)
|
||||
})
|
||||
}
|
||||
|
||||
// TestSearchIndexFollowsContent is the AUD-151 freshness line: a content change
|
||||
// followed by a build is reflected in search. The deploy path is exactly this —
|
||||
// deploy/deploy.sh runs `make dist` before it stages a release, and the build
|
||||
// rewrites dist/search.db from content/ every time (search.Build removes the old
|
||||
// file), so search can never serve an index from a previous content tree.
|
||||
func TestSearchIndexFollowsContent(t *testing.T) {
|
||||
root, err := filepath.Abs("../..")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dir := t.TempDir()
|
||||
contentDir := filepath.Join(dir, "content")
|
||||
if err := copyTree(filepath.Join(root, "content"), contentDir); err != nil {
|
||||
t.Fatalf("copy content: %v", err)
|
||||
}
|
||||
out := filepath.Join(dir, "dist")
|
||||
|
||||
build := func() {
|
||||
t.Helper()
|
||||
s, err := New(Config{Root: root, ContentDir: contentDir, OutDir: out})
|
||||
if err != nil {
|
||||
t.Fatalf("load site: %v", err)
|
||||
}
|
||||
if err := s.Build(); err != nil {
|
||||
t.Fatalf("build: %v", err)
|
||||
}
|
||||
}
|
||||
query := func(q string) []search.Result {
|
||||
t.Helper()
|
||||
idx, err := search.Open(filepath.Join(out, IndexFile))
|
||||
if err != nil {
|
||||
t.Fatalf("open index: %v", err)
|
||||
}
|
||||
defer idx.Close()
|
||||
results, err := idx.Query(q, 20)
|
||||
if err != nil {
|
||||
t.Fatalf("query %q: %v", q, err)
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
const token = "kumquatprotocol"
|
||||
build()
|
||||
if got := query(token); len(got) != 0 {
|
||||
t.Fatalf("token is already indexed in %d results; the test proves nothing", len(got))
|
||||
}
|
||||
|
||||
page := filepath.Join(contentDir, "edge", "edge-fleet.md")
|
||||
raw, err := os.ReadFile(page)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
added := string(raw) + "\n\nA " + token + " harness keeps the nodes in step.\n"
|
||||
if err := os.WriteFile(page, []byte(added), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
build()
|
||||
results := query(token)
|
||||
if len(results) == 0 {
|
||||
t.Fatal("content change is not in the rebuilt index")
|
||||
}
|
||||
if results[0].URL != "/edge/edge-fleet/" {
|
||||
t.Errorf("token found at %s, want /edge/edge-fleet/", results[0].URL)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -882,6 +882,15 @@ samp {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.search-areas {
|
||||
margin: 0.6rem 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem 1.1rem;
|
||||
}
|
||||
|
||||
.results {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
@@ -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