Tabs (HATEOAS)

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}}

tabs.tmpl

Handler & state

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",
	}))
}

handlers_navigation.go:94-129

type TabsState struct {
	Title     string
	Category  string
	ActiveTab string
}

state_navigation.go:24-29

When to use

This is SPA Navigation applied to one link group; reach for that pattern when whole-page links need the same interception.

source: livetemplate/docs · path: examples/patterns/templates/navigation/tabs.tmpl