Render tabs as plain ?tab=… links and let the framework intercept the click and
route it over the WebSocket via the in-band __navigate__ action — no HTTP round-trip
and no client-side tab state. Mount() re-runs with the new query param, picks the
active tab from a validTabs allowlist, and re-renders the panel server-side, so deep
links like ?tab=settings work on a cold load too.
Tabs (HATEOAS)
Server-driven tabs. Each tab is a query-param link (?tab=…); the framework intercepts the click and routes it through the WebSocket via the in-band __navigate__ action (server v0.8.19+, client v0.8.26+). No HTTP round-trip; Mount() re-runs with the new param.
Overview
This pattern uses a single Mount handler reading the tab query parameter. The active link is marked with aria-current="page" for screen readers and visually styled by Pico CSS.
Template
Each tab is an anchor with ?tab=; the active one gets aria-current="page". The
panel body is a server-rendered {{if eq .ActiveTab …}} branch — switching tabs is a
re-render, not a partial fragment.
{{define "content"}}
<article>
<h3>Tabs (HATEOAS)</h3>
<p><small>Server-driven tabs. Each tab is a query-param link (<code>?tab=…</code>); the framework intercepts the click and routes it through the WebSocket via the in-band <code>__navigate__</code> action (server v0.8.19+, client v0.8.26+). No HTTP round-trip; <code>Mount()</code> re-runs with the new param.</small></p>
<nav aria-label="Sections">
<ul>
<li><a href="?tab=overview" {{if eq .ActiveTab "overview"}}aria-current="page"{{end}}>Overview</a></li>
<li><a href="?tab=settings" {{if eq .ActiveTab "settings"}}aria-current="page"{{end}}>Settings</a></li>
<li><a href="?tab=activity" {{if eq .ActiveTab "activity"}}aria-current="page"{{end}}>Activity</a></li>
</ul>
</nav>
{{if eq .ActiveTab "overview"}}
<section>
<h4>Overview</h4>
<p>This pattern uses a single <code>Mount</code> handler reading the <code>tab</code> query parameter. The active link is marked with <code>aria-current="page"</code> for screen readers and visually styled by Pico CSS.</p>
</section>
{{else if eq .ActiveTab "settings"}}
<section>
<h4>Settings</h4>
<p>Tab content is rendered server-side from the same template — switching tabs is a re-render, not a partial fragment. Bookmark <code>?tab=settings</code> and the deep link works on next load too.</p>
</section>
{{else if eq .ActiveTab "activity"}}
<section>
<h4>Activity</h4>
<p>Unknown <code>tab</code> values fall back silently to <strong>Overview</strong>. The valid set lives in a <code>validTabs</code> allowlist; bookmarks with stale values stay safe.</p>
</section>
{{end}}
</article>
{{end}}
A single Mount handler reads the tab param, validates it against an allowlist, and
falls back to overview for unknown or stale values.
// Mount-only: tab links use the in-band __navigate__ action, which re-runs
// Mount with ctx.Action()=="" so the same guard covers initial GET.
type TabsController struct{}
var validTabs = map[string]bool{"overview": true, "settings": true, "activity": true}
func (c *TabsController) Mount(state TabsState, ctx *livetemplate.Context) (TabsState, error) {
if ctx.Action() == "" {
t := ctx.GetString("tab")
switch {
case t != "" && validTabs[t]:
state.ActiveTab = t
case t != "":
// Explicit unknown tab → reset to overview (matches template promise).
state.ActiveTab = "overview"
case !validTabs[state.ActiveTab]:
// First load (no param, empty state) → default to overview.
state.ActiveTab = "overview"
}
}
// Invariant: by here ActiveTab is always in validTabs. Belt-and-suspenders
// in case a malformed message routes through Mount with ctx.Action() != "".
if !validTabs[state.ActiveTab] {
state.ActiveTab = "overview"
}
return state, nil
}
func tabsHandler() http.Handler {
tmpl := newLayoutTmpl("templates/layout.tmpl", "templates/navigation/tabs.tmpl")
return tmpl.Handle(&TabsController{}, livetemplate.AsState(&TabsState{
Title: "Tabs (HATEOAS)",
Category: "Dialogs, Tabs & Navigation",
}))
}