Add js web stuff to landing page + documentation
This commit is contained in:
136
go/cmd/kjol-web/app/chart.go
Normal file
136
go/cmd/kjol-web/app/chart.go
Normal file
@@ -0,0 +1,136 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"math/rand"
|
||||
|
||||
chart "github.com/wcharczuk/go-chart/v2"
|
||||
|
||||
. "kjol/vdom"
|
||||
ui "kjol/webui"
|
||||
)
|
||||
|
||||
var chartLabels = []string{"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"}
|
||||
|
||||
// fixed initial data so the server SSR and the client's first render match.
|
||||
func fixedChartData() []int { return []int{42, 17, 63, 28, 55, 9, 71} }
|
||||
|
||||
func randomValues() []int {
|
||||
v := make([]int, len(chartLabels))
|
||||
for i := range v {
|
||||
v[i] = rand.Intn(95) + 5
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func renderSVG(c interface {
|
||||
Render(chart.RendererProvider, io.Writer) error
|
||||
}) string {
|
||||
var buf bytes.Buffer
|
||||
if c.Render(chart.SVG, &buf) != nil {
|
||||
return "<p class=\"text-danger m-0\">chart error</p>"
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func barSVG(values []int) string {
|
||||
bars := make([]chart.Value, len(values))
|
||||
for i, v := range values {
|
||||
bars[i] = chart.Value{Value: float64(v), Label: chartLabels[i%len(chartLabels)]}
|
||||
}
|
||||
return renderSVG(&chart.BarChart{
|
||||
Title: "Weekly values (bar)",
|
||||
TitleStyle: chart.Style{FontSize: 15},
|
||||
Background: chart.Style{Padding: chart.Box{Top: 48, Left: 16, Right: 16, Bottom: 16}},
|
||||
Height: 320, BarWidth: 48, Bars: bars,
|
||||
})
|
||||
}
|
||||
|
||||
func pieSVG(values []int) string {
|
||||
vs := make([]chart.Value, len(values))
|
||||
for i, v := range values {
|
||||
vs[i] = chart.Value{Value: float64(v), Label: chartLabels[i%len(chartLabels)]}
|
||||
}
|
||||
return renderSVG(&chart.PieChart{
|
||||
Title: "Share by day (pie)",
|
||||
TitleStyle: chart.Style{FontSize: 15},
|
||||
Background: chart.Style{Padding: chart.Box{Top: 48}},
|
||||
Width: 320, Height: 320, Values: vs,
|
||||
})
|
||||
}
|
||||
|
||||
//gowasm:page /wasm/chart static layout=app
|
||||
func ChartPage(d Deps) func() *VNode {
|
||||
data := NewSignal(fixedChartData())
|
||||
|
||||
return func() *VNode {
|
||||
values := data.Get()
|
||||
|
||||
return docPage("Rendering", "SSR & hydration",
|
||||
"A static route is rendered to HTML by the server, so the page is complete before any "+
|
||||
"WebAssembly has downloaded. The same component then runs in the browser, adopts the markup "+
|
||||
"that is already there, and takes over. One function, two runtimes.",
|
||||
|
||||
docSection("the-directive", "Marking a route static",
|
||||
prose("static on the page directive is what puts a route in the server's pre-render set. Leave "+
|
||||
"it off and the route renders on the client only — which is the right choice when the page "+
|
||||
"is behind a login, or its content depends on something only the browser knows."),
|
||||
code("app/chart.go", chartSnippet),
|
||||
note("Hydration adopts, it does not rebuild",
|
||||
"The client renders the same tree the server did and walks the existing DOM alongside it, "+
|
||||
"wiring event handlers to the nodes that are already on the page. If the two trees "+
|
||||
"disagree, the CLIENT wins — a stale server binary should not be able to pin a wrong "+
|
||||
"class onto the page forever."),
|
||||
),
|
||||
|
||||
docSection("charts", "A worked example: charts",
|
||||
prose("These charts are SVG produced by go-chart — a plain Go library that knows nothing about "+
|
||||
"the browser. The server draws them and ships the markup inline; there is no chart "+
|
||||
"JavaScript, and no canvas that has to wait for the client to boot before it shows anything."),
|
||||
prose("Shuffle re-runs the same drawing code in the browser. The first render came from the "+
|
||||
"server and the next one comes from WebAssembly, and the page cannot tell the difference."),
|
||||
|
||||
Div(Attr("class", "mt-4"),
|
||||
ui.Button(ui.ButtonProps{Color: ui.ButtonPrimary, Icon: "chart-column", Text: "Shuffle data",
|
||||
OnClick: func() { data.Set(randomValues()) }}),
|
||||
),
|
||||
Div(Attr("class", "mt-4 grid gap-4 lg:grid-cols-12"),
|
||||
Div(Attr("class", "lg:col-span-7 rounded-default border border-line bg-surface p-3 shadow-xs overflow-auto"), Raw(barSVG(values))),
|
||||
Div(Attr("class", "lg:col-span-5 rounded-default border border-line bg-surface p-3 shadow-xs overflow-auto"), Raw(pieSVG(values))),
|
||||
),
|
||||
|
||||
note("go-chart lives in the EXAMPLE, not in kjol",
|
||||
"The engine is standard-library-only. This example is its own Go module precisely so a "+
|
||||
"charting dependency it happens to want does not become a dependency of everyone who "+
|
||||
"uses the framework."),
|
||||
),
|
||||
|
||||
docSection("api", "Reference",
|
||||
apiTable(
|
||||
apiRow{"//gowasm:page /path static", "Pre-render this route on the server, then hydrate it."},
|
||||
apiRow{"vdom.RenderHTML", "Render a tree to an HTML string. This is what the server calls."},
|
||||
apiRow{"wasmruntime.Hydrate", "Adopt server-rendered DOM instead of building it. The client's entry point for a static route."},
|
||||
apiRow{"vdom.Raw", "Insert markup verbatim — how the server-drawn SVG gets in. The reconciler clears it correctly when the element is reused."},
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const chartSnippet = `//gowasm:page /wasm/chart static layout=app
|
||||
func ChartPage(d Deps) func() *VNode {
|
||||
data := NewSignal(fixedChartData())
|
||||
|
||||
return func() *VNode {
|
||||
// go-chart draws an SVG string — on the server for the first paint,
|
||||
// and in the browser for every render after that.
|
||||
return Div(
|
||||
ui.Button(ui.ButtonProps{
|
||||
Text: "Shuffle data",
|
||||
OnClick: func() { data.Set(randomValues()) },
|
||||
}),
|
||||
Div(Raw(barSVG(data.Get()))),
|
||||
)
|
||||
}
|
||||
}`
|
||||
13
go/cmd/kjol-web/app/client.gen.go
Normal file
13
go/cmd/kjol-web/app/client.gen.go
Normal file
@@ -0,0 +1,13 @@
|
||||
// Code generated by wasmgen. DO NOT EDIT.
|
||||
|
||||
//go:build js && wasm
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"kjol/rsc"
|
||||
"kjol/vdom"
|
||||
)
|
||||
|
||||
// ServerCounter is a generated client stub for the server component of the same name.
|
||||
func ServerCounter() func() *vdom.VNode { return rsc.Mount("ServerCounter") }
|
||||
182
go/cmd/kjol-web/app/data.go
Normal file
182
go/cmd/kjol-web/app/data.go
Normal file
@@ -0,0 +1,182 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"kjol/httputil"
|
||||
. "kjol/vdom"
|
||||
ui "kjol/webui"
|
||||
)
|
||||
|
||||
// Quote is the payload the /api/quotes endpoint returns. The server encodes a
|
||||
// []Quote with httputil.RespondGob; the client decodes it straight back into
|
||||
// []Quote — the SAME Go type, no JSON, no hand-written unmarshalling.
|
||||
type Quote struct {
|
||||
Author string
|
||||
Text string
|
||||
}
|
||||
|
||||
// repoInfo is a subset of GitHub's repo JSON (a third-party API), tagged for
|
||||
// json decoding.
|
||||
type repoInfo struct {
|
||||
FullName string `json:"full_name"`
|
||||
Description string `json:"description"`
|
||||
Stars int `json:"stargazers_count"`
|
||||
}
|
||||
|
||||
//gowasm:page /wasm/data layout=app static
|
||||
func DataPage(d Deps) func() *VNode {
|
||||
// (1) gob from our own server via httputil.RespondGob / FetchGob.
|
||||
quotes := NewSignal([]Quote{})
|
||||
qLoading := NewSignal(true)
|
||||
qErr := NewSignal("")
|
||||
// (2) JSON from a third-party API (GitHub), for a user-entered repo.
|
||||
repo := NewSignal(repoInfo{})
|
||||
rLoading := NewSignal(true)
|
||||
rErr := NewSignal("")
|
||||
repoQuery := NewSignal("golang/go")
|
||||
started := false
|
||||
|
||||
// fetchRepo loads owner/name from the GitHub API into the repo signal.
|
||||
fetchRepo := func(q string) {
|
||||
q = strings.Trim(strings.TrimSpace(q), "/")
|
||||
if q == "" {
|
||||
rErr.Set("enter a repo as owner/name")
|
||||
rLoading.Set(false)
|
||||
return
|
||||
}
|
||||
rErr.Set("")
|
||||
rLoading.Set(true)
|
||||
httputil.FetchJSON("https://api.github.com/repos/"+q, func(r repoInfo, err error) {
|
||||
if err != nil {
|
||||
rErr.Set(err.Error())
|
||||
} else {
|
||||
repo.Set(r)
|
||||
}
|
||||
rLoading.Set(false)
|
||||
})
|
||||
}
|
||||
|
||||
return func() *VNode {
|
||||
// Fire the initial fetches once, on the client (no transport on the server,
|
||||
// so SSR ships the loading state and the client takes over).
|
||||
if !started {
|
||||
started = true
|
||||
httputil.FetchGob("/api/quotes", func(qs []Quote, err error) {
|
||||
if err != nil {
|
||||
qErr.Set(err.Error())
|
||||
} else {
|
||||
quotes.Set(qs)
|
||||
}
|
||||
qLoading.Set(false)
|
||||
})
|
||||
fetchRepo(repoQuery.Get())
|
||||
}
|
||||
|
||||
return docPage("Rendering", "Data fetching",
|
||||
"Fetching happens in the browser, so a server-rendered page ships its LOADING state and the "+
|
||||
"client fills it in. Two shapes are shown here: gob against your own server, where the same "+
|
||||
"Go type crosses the wire untranslated, and JSON against somebody else's API.",
|
||||
|
||||
docSection("gob", "gob — the same Go type on both ends",
|
||||
prose("Your server already speaks Go and so does your client, so there is no reason to translate "+
|
||||
"through JSON in between. The handler answers with httputil.RespondGob([]Quote) and the "+
|
||||
"client decodes straight back into []Quote — one type, declared once, with no tags and no "+
|
||||
"hand-written unmarshalling to drift out of sync with it."),
|
||||
code("app/data.go + server/main.go", gobSnippet),
|
||||
demo("GET /api/quotes, decoded into []Quote",
|
||||
quotesBody(qLoading.Get(), qErr.Get(), quotes.Get()),
|
||||
),
|
||||
),
|
||||
|
||||
docSection("json", "JSON — for everyone else's API",
|
||||
prose("A third-party API does not speak gob, so httputil.FetchJSON decodes into a tagged struct "+
|
||||
"the ordinary way. Enter a repository and the browser calls api.github.com directly."),
|
||||
demo("GET api.github.com/repos/…, decoded into a tagged struct",
|
||||
row("mb-4 flex items-end gap-2",
|
||||
row("flex grow flex-col gap-1 max-w-sm",
|
||||
ui.FormLabel(ui.FormLabelProps{}, Text("GitHub repo (owner/name)")),
|
||||
ui.FormInput(ui.FormInputProps{
|
||||
Value: repoQuery.Get(),
|
||||
Placeholder: "golang/go",
|
||||
OnInput: func(v string) { repoQuery.Set(v) },
|
||||
}),
|
||||
),
|
||||
ui.Button(ui.ButtonProps{Color: ui.ButtonPrimary, Text: "Fetch", OnClick: func() { fetchRepo(repoQuery.Get()) }}),
|
||||
),
|
||||
repoBody(rLoading.Get(), rErr.Get(), repo.Get()),
|
||||
),
|
||||
),
|
||||
|
||||
docSection("ssr", "What the server renders",
|
||||
prose("This route is static, so the server pre-renders it — but there is no fetch on the server: "+
|
||||
"no transport is installed there, and inventing one would mean the server quietly making "+
|
||||
"requests on the user's behalf. So a fetch started during SSR does nothing at all, the page "+
|
||||
"renders its spinner, and the client runs the fetch for real once it has hydrated."),
|
||||
note("A fetch that fails on the server is a bug in the framework, not in your page",
|
||||
"An earlier version of this returned an error from SSR, and every static page that fetched "+
|
||||
"anything rendered \"no client transport installed\" into its own HTML. Loading is the "+
|
||||
"correct server-side answer to \"have you fetched this yet?\"."),
|
||||
apiTable(
|
||||
apiRow{"httputil.RespondGob", "Server: write a Go value as gob."},
|
||||
apiRow{"httputil.FetchGob", "Client: decode a gob response into a Go value."},
|
||||
apiRow{"httputil.FetchJSON", "Client: decode a JSON response into a tagged struct."},
|
||||
apiRow{"httputil.SetClientTransport", "Override the transport — a base URL, auth headers. The runtime installs a fetch-based one for you."},
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const gobSnippet = `// One type. Both ends. No tags, no JSON.
|
||||
type Quote struct {
|
||||
Author string
|
||||
Text string
|
||||
}
|
||||
|
||||
// --- server ---
|
||||
mux.HandleFunc("GET /api/quotes", func(w http.ResponseWriter, r *http.Request) {
|
||||
httputil.RespondGob(w, http.StatusOK, sampleQuotes()) // []Quote
|
||||
})
|
||||
|
||||
// --- client ---
|
||||
httputil.FetchGob("/api/quotes", func(qs []Quote, err error) {
|
||||
if err != nil { qErr.Set(err.Error()); return }
|
||||
quotes.Set(qs) // []Quote
|
||||
})`
|
||||
|
||||
func quotesBody(loading bool, failed string, quotes []Quote) *VNode {
|
||||
switch {
|
||||
case failed != "":
|
||||
return ui.Alert(ui.AlertRed, "Fetch failed", Text(failed))
|
||||
case loading:
|
||||
return ui.Loader()
|
||||
default:
|
||||
cards := make([]*VNode, 0, len(quotes))
|
||||
for _, q := range quotes {
|
||||
cards = append(cards, ui.BorderCard("",
|
||||
P(Attr("class", "text-ink"), Text("“"+q.Text+"”")),
|
||||
P(Attr("class", "mt-2 text-sm text-ink-muted"), Text("— "+q.Author)),
|
||||
))
|
||||
}
|
||||
return row("grid gap-3 sm:grid-cols-2", cards...)
|
||||
}
|
||||
}
|
||||
|
||||
func repoBody(loading bool, failed string, r repoInfo) *VNode {
|
||||
switch {
|
||||
case failed != "":
|
||||
return ui.Alert(ui.AlertRed, "Fetch failed", Text(failed))
|
||||
case loading:
|
||||
return ui.Loader()
|
||||
default:
|
||||
return ui.BorderCard("",
|
||||
row("flex items-center gap-2",
|
||||
Strong(Attr("class", "text-ink"), Text(r.FullName)),
|
||||
ui.Badge(ui.BadgeProps{Color: ui.BadgeAmber}, Text("★ "+strconv.Itoa(r.Stars))),
|
||||
),
|
||||
P(Attr("class", "mt-2 text-sm text-ink-soft"), Text(r.Description)),
|
||||
)
|
||||
}
|
||||
}
|
||||
276
go/cmd/kjol-web/app/docs.go
Normal file
276
go/cmd/kjol-web/app/docs.go
Normal file
@@ -0,0 +1,276 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
. "kjol/vdom"
|
||||
ui "kjol/webui"
|
||||
)
|
||||
|
||||
// Documentation chrome.
|
||||
//
|
||||
// The app routes are the framework's documentation, so they are built from one small
|
||||
// vocabulary rather than each page inventing its own headings and spacing: a page has a
|
||||
// title and a lede, then sections; a section explains something in prose, shows the Go
|
||||
// that does it, and then RUNS that Go on the page you are reading. The last part is the
|
||||
// point — a docs page for a UI framework that only shows screenshots of its components
|
||||
// is a docs page that cannot tell you when it has gone stale.
|
||||
|
||||
// docsNav is the sidebar: the sections of the documentation, in reading order.
|
||||
//
|
||||
// It is data, not markup, because it is consumed twice — once by the sidebar and once
|
||||
// by the /docs index, which lists the same pages as cards. Two hand-written copies of a
|
||||
// nav is two copies to forget to update.
|
||||
type docsGroup struct {
|
||||
Title string
|
||||
Items []docsItem
|
||||
}
|
||||
|
||||
type docsItem struct {
|
||||
Path string
|
||||
Label string
|
||||
Blurb string // shown on the /docs index; too long for the sidebar
|
||||
Icon string
|
||||
}
|
||||
|
||||
func docsNav() []docsGroup {
|
||||
return []docsGroup{{
|
||||
Title: "Introduction",
|
||||
Items: []docsItem{
|
||||
{Path: "/wasm", Label: "Overview", Icon: "book-open",
|
||||
Blurb: "What Kjol Web is, how a page becomes a WebAssembly binary, and what runs where."},
|
||||
},
|
||||
}, {
|
||||
Title: "Rendering",
|
||||
Items: []docsItem{
|
||||
{Path: "/wasm/chart", Label: "SSR & hydration", Icon: "chart-column",
|
||||
Blurb: "The same Go renders HTML on the server and takes over in the browser. Charts, server-drawn as SVG."},
|
||||
{Path: "/wasm/server", Label: "Server components", Icon: "server",
|
||||
Blurb: "Components whose state and code stay on the server. Calling one looks like calling any other."},
|
||||
{Path: "/wasm/data", Label: "Data fetching", Icon: "cloud-arrow-down",
|
||||
Blurb: "gob to your own server (Go types end to end, no JSON) and JSON to a third-party API."},
|
||||
},
|
||||
}, {
|
||||
Title: "Components",
|
||||
Items: []docsItem{
|
||||
{Path: "/wasm/kit", Label: "UI kit", Icon: "squares",
|
||||
Blurb: "Buttons, forms, tabs, alerts, cards — the kjol/webui components, written in Go."},
|
||||
{Path: "/wasm/overlays", Label: "Overlays", Icon: "layers",
|
||||
Blurb: "Tooltips, popovers, menus, modals: measured against the real viewport, flipped and shifted to fit."},
|
||||
{Path: "/wasm/table", Label: "AutoTable", Icon: "table",
|
||||
Blurb: "Filtering, sorting, column management, calculated columns, CSV and PDF export."},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
// ---- page scaffolding ---------------------------------------------------
|
||||
|
||||
// docPage is the frame every documentation page shares: an eyebrow, a title, a lede,
|
||||
// and then its sections.
|
||||
func docPage(eyebrow, title, lede string, sections ...*VNode) *VNode {
|
||||
mods := []Mod{Attr("class", "pb-16")}
|
||||
mods = append(mods,
|
||||
Div(Attr("class", "border-b border-line pb-6"),
|
||||
P(Attr("class", "text-xs font-semibold uppercase tracking-widest text-accent"), Text(eyebrow)),
|
||||
H1(Attr("class", "mt-2 text-3xl font-semibold tracking-tight text-text-heading"), Text(title)),
|
||||
P(Attr("class", "mt-3 max-w-3xl text-ink-muted leading-relaxed"), Text(lede)),
|
||||
),
|
||||
)
|
||||
for _, s := range sections {
|
||||
mods = append(mods, s)
|
||||
}
|
||||
return Div(mods...)
|
||||
}
|
||||
|
||||
// docSection is a titled slab of the page. The id is what the "on this page" links and
|
||||
// the tour steps anchor to.
|
||||
func docSection(id, title string, body ...*VNode) *VNode {
|
||||
mods := []Mod{Attr("id", id), Attr("class", "mt-12 scroll-mt-24")}
|
||||
mods = append(mods,
|
||||
H2(Attr("class", "text-xl font-semibold tracking-tight text-text-heading"), Text(title)),
|
||||
)
|
||||
for _, b := range body {
|
||||
mods = append(mods, b)
|
||||
}
|
||||
return El("section", mods...)
|
||||
}
|
||||
|
||||
// prose is a paragraph of explanation. Constrained to a reading measure: a line of body
|
||||
// text that runs the full width of a wide screen is genuinely harder to read, and the
|
||||
// demos beside it are allowed to be as wide as they like.
|
||||
func prose(text string) *VNode {
|
||||
return P(Attr("class", "mt-3 max-w-3xl text-ink-soft leading-relaxed"), Text(text))
|
||||
}
|
||||
|
||||
// ---- code ---------------------------------------------------------------
|
||||
|
||||
// code is a Go snippet, captioned with where it comes from.
|
||||
//
|
||||
// The caption is a real file path in this example, not a decoration: every snippet on
|
||||
// these pages is copied from code that actually runs, and saying where from is what
|
||||
// lets you go and check.
|
||||
func code(caption, src string) *VNode { return codeLang(caption, "Go", src) }
|
||||
|
||||
// codeLang is code() for a block that is not Go — a shell session, a formula. The label
|
||||
// in the corner says what you are looking at, and a shell command labelled "Go" is worse
|
||||
// than no label at all.
|
||||
//
|
||||
// Go blocks are syntax-highlighted (webui.HighlightGo); the others are shown verbatim.
|
||||
// A shell transcript put through a Go lexer comes out with `serving` painted as an
|
||||
// identifier and quotes as string literals — highlighting the wrong language is more
|
||||
// distracting than not highlighting at all.
|
||||
func codeLang(caption, lang, src string) *VNode {
|
||||
var body *VNode
|
||||
if lang == "Go" {
|
||||
// Raw, not Text: HighlightGo returns HTML. It escapes every run of source on the
|
||||
// way out, so the snippets that contain markup stay inert.
|
||||
body = El("code", Raw(ui.HighlightGo(src)))
|
||||
} else {
|
||||
body = El("code", Text(src))
|
||||
}
|
||||
|
||||
return Div(Attr("class", "mt-4 overflow-hidden rounded-default border border-neutral-800 bg-neutral-900"),
|
||||
Div(Attr("class", "flex items-center gap-2 border-b border-neutral-800 px-4 py-2"),
|
||||
Span(Attr("class", "text-xs font-medium text-ink-faint font-mono"), Text(caption)),
|
||||
Span(Attr("class", "ml-auto rounded-full bg-white/5 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-ink-faint"), Text(lang)),
|
||||
),
|
||||
Pre(Attr("class", "overflow-x-auto px-4 py-3 text-[13px] leading-relaxed text-neutral-100 font-mono"), body),
|
||||
)
|
||||
}
|
||||
|
||||
// ---- demos --------------------------------------------------------------
|
||||
|
||||
// demo is the panel a section's example sits in, captioned with what it is showing.
|
||||
func demo(title string, body ...*VNode) *VNode {
|
||||
mods := []Mod{Attr("class", "mt-4 rounded-default border border-line bg-surface shadow-xs")}
|
||||
mods = append(mods,
|
||||
Div(Attr("class", "border-b border-line px-4 py-2"),
|
||||
Span(Attr("class", "text-xs text-ink-muted"), Text(title)),
|
||||
),
|
||||
)
|
||||
inner := []Mod{Attr("class", "p-4")}
|
||||
for _, b := range body {
|
||||
inner = append(inner, b)
|
||||
}
|
||||
mods = append(mods, Div(inner...))
|
||||
return Div(mods...)
|
||||
}
|
||||
|
||||
// note is an aside — a caveat, a gotcha, the reason something is the way it is.
|
||||
func note(title, body string) *VNode {
|
||||
return Div(Attr("class", "mt-4 max-w-3xl rounded-default border border-primary-border bg-primary-subtle px-4 py-3"),
|
||||
P(Attr("class", "text-sm font-semibold text-text-heading"), Text(title)),
|
||||
P(Attr("class", "mt-1 text-sm text-ink-soft leading-relaxed"), Text(body)),
|
||||
)
|
||||
}
|
||||
|
||||
// ---- reference tables ---------------------------------------------------
|
||||
|
||||
type apiRow struct{ Name, Desc string }
|
||||
|
||||
// apiTable is the reference half of a page: the names, and what each one does.
|
||||
func apiTable(rows ...apiRow) *VNode {
|
||||
body := make([]*VNode, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
body = append(body, El("tr", Attr("class", "border-t border-line"),
|
||||
El("td", Attr("class", "py-2 pr-4 align-top whitespace-nowrap"),
|
||||
El("code", Attr("class", "rounded bg-surface-raised px-1.5 py-0.5 text-[13px] font-mono text-ink"), Text(r.Name))),
|
||||
El("td", Attr("class", "py-2 text-sm text-ink-soft leading-relaxed"), Text(r.Desc)),
|
||||
))
|
||||
}
|
||||
rowMods := []Mod{}
|
||||
for _, b := range body {
|
||||
rowMods = append(rowMods, b)
|
||||
}
|
||||
return Div(Attr("class", "mt-4 max-w-5xl overflow-x-auto"),
|
||||
El("table", Attr("class", "w-full border-collapse text-left"),
|
||||
Tbody(rowMods...),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// ---- the docs index -----------------------------------------------------
|
||||
|
||||
//gowasm:page /wasm static layout=app
|
||||
func DocsPage(d Deps) func() *VNode {
|
||||
return func() *VNode {
|
||||
var groups []*VNode
|
||||
for _, g := range docsNav() {
|
||||
grid := []Mod{Attr("class", "mt-3 grid gap-3 sm:grid-cols-2")}
|
||||
for _, it := range g.Items {
|
||||
if it.Path == "/wasm" {
|
||||
continue // don't list this page on itself
|
||||
}
|
||||
grid = append(grid, docsCard(d, it))
|
||||
}
|
||||
if len(grid) == 1 {
|
||||
continue // the group held nothing but this page
|
||||
}
|
||||
groups = append(groups,
|
||||
Div(Attr("class", "mt-10"),
|
||||
H2(Attr("class", "text-sm font-semibold uppercase tracking-widest text-ink-faint"), Text(g.Title)),
|
||||
Div(grid...),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return docPage("Introduction", "Overview",
|
||||
"Kjol Web is kjol's Go→WebAssembly UI engine. You write components as ordinary Go functions "+
|
||||
"returning a virtual DOM; the server renders them to HTML and the same code hydrates them "+
|
||||
"in the browser. There is no JavaScript build step, and the engine depends on nothing "+
|
||||
"outside the standard library.",
|
||||
|
||||
docSection("what-runs-where", "What runs where",
|
||||
prose("A page is Go, compiled twice. On the server it renders to an HTML string, so the first "+
|
||||
"paint needs no WebAssembly at all. In the browser the same functions run again, adopt the "+
|
||||
"markup that is already there, and from then on a signal write re-renders and reconciles into "+
|
||||
"the live DOM."),
|
||||
code("app/pages.go", ssrSnippet),
|
||||
note("The host API is dual-build",
|
||||
"Components measure the DOM — a tooltip has to know where its trigger is. Those calls are "+
|
||||
"real under js/wasm and no-ops natively, which is what lets one component both SSR and "+
|
||||
"position itself, without a branch in the component."),
|
||||
),
|
||||
|
||||
appendNodes(Div(Attr("class", "mt-14 border-t border-line pt-2")), groups...),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func docsCard(d Deps, it docsItem) *VNode {
|
||||
return A(
|
||||
Attr("class", "group block rounded-default border border-line bg-surface p-4 no-underline shadow-xs transition hover:border-primary-border hover:shadow-sm"),
|
||||
Attr("href", it.Path), navigate(d, it.Path),
|
||||
Div(Attr("class", "flex items-center gap-2"),
|
||||
Span(Attr("class", "inline-flex h-7 w-7 items-center justify-center rounded-default bg-primary-subtle text-accent"),
|
||||
ui.IconInline(it.Icon, 14, "")),
|
||||
Span(Attr("class", "font-semibold text-text-heading"), Text(it.Label)),
|
||||
Span(Attr("class", "ml-auto text-ink-faint transition group-hover:text-accent"), ui.IconInline("arrow-right", 12, "")),
|
||||
),
|
||||
P(Attr("class", "mt-2 text-sm text-ink-muted leading-relaxed"), Text(it.Blurb)),
|
||||
)
|
||||
}
|
||||
|
||||
// appendNodes adds children to a node after the fact — the shape a few of these pages
|
||||
// need, where the section list is computed rather than written out.
|
||||
func appendNodes(parent *VNode, children ...*VNode) *VNode {
|
||||
parent.Children = append(parent.Children, children...)
|
||||
return parent
|
||||
}
|
||||
|
||||
const ssrSnippet = `//gowasm:page /wasm static layout=app
|
||||
func DocsPage(d Deps) func() *VNode {
|
||||
count := NewSignal(0) // state lives in the closure
|
||||
|
||||
return func() *VNode { // the render: pure, called again on every change
|
||||
return Div(Attr("class", "space-y-2"),
|
||||
H1(Text("Overview")),
|
||||
Button(
|
||||
Attr("class", "btn"),
|
||||
On(EVENT_CLICK, func() { count.Set(count.Get() + 1) }),
|
||||
Text("clicked "+itoa(count.Get())+" times"),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// static => the server pre-renders this route to HTML.
|
||||
// The same function then hydrates it in the browser.`
|
||||
82
go/cmd/kjol-web/app/icons_test.go
Normal file
82
go/cmd/kjol-web/app/icons_test.go
Normal file
@@ -0,0 +1,82 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
ui "kjol/webui"
|
||||
)
|
||||
|
||||
// Every icon name this app names must actually resolve.
|
||||
//
|
||||
// An unregistered name renders an empty, correctly-sized box. That is the right thing
|
||||
// at runtime — a missing icon should not collapse the layout — but it means a typo is
|
||||
// invisible: the icon is simply absent, and nothing says why. Two of them (shapes,
|
||||
// layer-group, which the kit calls squares and layers) shipped in the sidebar looking
|
||||
// like blank squares before this test existed.
|
||||
//
|
||||
// It scans the SOURCE rather than a hand-kept list, so an icon added to a page tomorrow
|
||||
// is checked tomorrow, without anyone remembering to add it here.
|
||||
func TestEveryIconNameResolves(t *testing.T) {
|
||||
// ui.Icon("x", …) / ui.IconInline("x", …), and the Icon: "x" field on the props
|
||||
// structs (buttons, menu items, docs nav).
|
||||
patterns := []*regexp.Regexp{
|
||||
regexp.MustCompile(`Icon(?:Inline)?\("([a-z0-9-]+)"`),
|
||||
regexp.MustCompile(`\bIcon:\s*"([a-z0-9-]+)"`),
|
||||
}
|
||||
|
||||
files, err := filepath.Glob("*.go")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
used := map[string][]string{} // icon name -> files that ask for it
|
||||
for _, f := range files {
|
||||
if strings.HasSuffix(f, "_test.go") {
|
||||
continue
|
||||
}
|
||||
src, err := os.ReadFile(f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, re := range patterns {
|
||||
for _, m := range re.FindAllStringSubmatch(string(src), -1) {
|
||||
used[m[1]] = append(used[m[1]], f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(used) == 0 {
|
||||
t.Fatal("scanned the package and found no icon names at all — the patterns have gone stale")
|
||||
}
|
||||
|
||||
names := make([]string, 0, len(used))
|
||||
for n := range used {
|
||||
names = append(names, n)
|
||||
}
|
||||
sort.Strings(names)
|
||||
|
||||
for _, n := range names {
|
||||
if !ui.HasIcon(n) {
|
||||
t.Errorf("icon %q is not registered (used in %s) — it will render as an empty box",
|
||||
n, strings.Join(dedupe(used[n]), ", "))
|
||||
}
|
||||
}
|
||||
t.Logf("checked %d icon names", len(names))
|
||||
}
|
||||
|
||||
func dedupe(in []string) []string {
|
||||
seen := map[string]bool{}
|
||||
out := in[:0:0]
|
||||
for _, s := range in {
|
||||
if !seen[s] {
|
||||
seen[s] = true
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
355
go/cmd/kjol-web/app/kit.go
Normal file
355
go/cmd/kjol-web/app/kit.go
Normal file
@@ -0,0 +1,355 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
. "kjol/vdom"
|
||||
ui "kjol/webui"
|
||||
)
|
||||
|
||||
// orElse is a fallback for an empty string.
|
||||
func orElse(s, fallback string) string {
|
||||
if s == "" {
|
||||
return fallback
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// row is a flex/grid container helper (appends *VNode children as Mods).
|
||||
func row(class string, children ...*VNode) *VNode {
|
||||
mods := []Mod{Attr("class", class)}
|
||||
for _, c := range children {
|
||||
mods = append(mods, c)
|
||||
}
|
||||
return Div(mods...)
|
||||
}
|
||||
|
||||
// kitSection is one labelled block of the gallery — a live demo panel, so that what you
|
||||
// are looking at is unmistakably the component running rather than a picture of it.
|
||||
func kitSection(title string, body ...*VNode) *VNode {
|
||||
return demo(title, row("flex flex-col gap-4", body...))
|
||||
}
|
||||
|
||||
func ptRow(name, plan string, status *VNode) *VNode {
|
||||
td := func(cls string, c *VNode) *VNode { return El("td", Attr("class", "px-3 py-2 text-sm "+cls), c) }
|
||||
return El("tr",
|
||||
td("text-ink", Text(name)),
|
||||
td("text-ink-soft", Text(plan)),
|
||||
El("td", Attr("class", "px-3 py-2 text-sm text-right"), status),
|
||||
)
|
||||
}
|
||||
|
||||
// languageOptions is deliberately longer than the pill limit, so the multi-select
|
||||
// demonstrates both ways it collapses: past 3 selections it says "N items selected"
|
||||
// outright, and below that it still collapses if the pills are too wide for the field.
|
||||
func languageOptions() []ui.FormSelectOption {
|
||||
return []ui.FormSelectOption{
|
||||
{Value: "go", Label: "Go"},
|
||||
{Value: "rust", Label: "Rust"},
|
||||
{Value: "ts", Label: "TypeScript"},
|
||||
{Value: "python", Label: "Python"},
|
||||
{Value: "kotlin", Label: "Kotlin"},
|
||||
{Value: "swift", Label: "Swift"},
|
||||
}
|
||||
}
|
||||
|
||||
//gowasm:page /wasm/kit layout=app
|
||||
func KitPage(d Deps) func() *VNode {
|
||||
// Interactive demos own their state via signals (a write re-renders).
|
||||
tab := NewSignal(0)
|
||||
acc := NewSignal(0)
|
||||
notify := NewSignal(true)
|
||||
span := NewSignal("week")
|
||||
name := NewSignal("")
|
||||
email := NewSignal("")
|
||||
plan := NewSignal("pro")
|
||||
langs := NewSignal([]string{"go"})
|
||||
|
||||
// Floating components are CONTROLLERS: they own refs, timers and open state, so
|
||||
// they are built once here — never inside the render closure below, which would
|
||||
// rebuild them (and lose their state) on every frame.
|
||||
modal := ui.NewModal(ui.ModalOptions{Size: ui.ModalMedium})
|
||||
menu := ui.NewMenu(ui.MenuOptions{Placement: ui.PlacementBottomStart})
|
||||
tip := ui.NewHoverTooltip(ui.PlacementTop, "")
|
||||
skills := ui.NewMultiSelect(ui.DropdownOptions{})
|
||||
|
||||
// The controls the first port left out, now that the host API can carry them.
|
||||
taxID := NewSignal("")
|
||||
rate := NewSignal("")
|
||||
signed := NewSignal("")
|
||||
picked := NewSignal("")
|
||||
tags := NewSignal([]string{"go"})
|
||||
|
||||
pad := ui.NewSignaturePad(ui.SignaturePadOptions{
|
||||
OnChange: func(svg string) { signed.Set(svg) },
|
||||
})
|
||||
// The search is the caller's: the component knows how to debounce, order and render,
|
||||
// and nothing at all about where options come from. Here it is a local slice; in an
|
||||
// app it would be a fetch.
|
||||
people := ui.NewAsyncCombobox(ui.AsyncComboboxOptions{
|
||||
MinChars: 2,
|
||||
Search: func(q string, done func([]ui.FormSelectOption)) {
|
||||
var out []ui.FormSelectOption
|
||||
for _, row := range employees() {
|
||||
p, ok := row.(Employee)
|
||||
if ok && strings.Contains(strings.ToLower(p.Name), strings.ToLower(q)) {
|
||||
out = append(out, ui.FormSelectOption{Value: p.Email, Label: p.Name})
|
||||
}
|
||||
}
|
||||
done(out)
|
||||
},
|
||||
})
|
||||
tagPicker := ui.NewMultiSelectTrigger(ui.DropdownOptions{})
|
||||
|
||||
return func() *VNode {
|
||||
return docPage("Components", "UI kit",
|
||||
"kjol/webui is the component library: buttons, badges, forms, tabs, alerts, cards, tables. "+
|
||||
"It is a Go port of the Solid.js kit the applications used before, styled with the same "+
|
||||
"Tailwind utilities — so the two can be swapped for one another a screen at a time.",
|
||||
|
||||
docSection("using", "Using a component",
|
||||
prose("Components are functions taking a props struct. There is no class hierarchy and nothing "+
|
||||
"to register: a component is a value, so you can build one, store it, pass it around, and "+
|
||||
"the compiler will tell you when you get it wrong."),
|
||||
code("app/kit.go", kitSnippet),
|
||||
note("Styling is Tailwind, compiled from your Go",
|
||||
"The Tailwind engine scans .go files for class names, because that is where the markup is. "+
|
||||
"There is no JavaScript build in this example at all — the CSS is compiled by a Go "+
|
||||
"program from Go source."),
|
||||
),
|
||||
|
||||
docSection("gallery", "The gallery",
|
||||
prose("Everything below is running. Click it."),
|
||||
),
|
||||
|
||||
kitSection("Buttons",
|
||||
row("flex flex-wrap items-center gap-2",
|
||||
ui.Button(ui.ButtonProps{Color: ui.ButtonPrimary, Text: "Primary"}),
|
||||
ui.Button(ui.ButtonProps{Color: ui.ButtonGreen, Text: "Green"}),
|
||||
ui.Button(ui.ButtonProps{Color: ui.ButtonRed, Text: "Red"}),
|
||||
ui.Button(ui.ButtonProps{Color: ui.ButtonBlue, Text: "Blue"}),
|
||||
ui.Button(ui.ButtonProps{Color: ui.ButtonNeutral, Text: "Neutral"}),
|
||||
),
|
||||
row("flex flex-wrap items-center gap-2",
|
||||
ui.Button(ui.ButtonProps{Color: ui.ButtonPrimary, Outline: true, Text: "Outline"}),
|
||||
ui.Button(ui.ButtonProps{Color: ui.ButtonRed, Outline: true, Text: "Danger"}),
|
||||
ui.Button(ui.ButtonProps{Color: ui.ButtonGreen, Small: true, Icon: "check", Text: "Small + icon"}),
|
||||
ui.Button(ui.ButtonProps{Color: ui.ButtonNeutral, Icon: "plus"}),
|
||||
ui.Button(ui.ButtonProps{Color: ui.ButtonPrimary, Text: "Disabled", Disabled: true}),
|
||||
),
|
||||
),
|
||||
|
||||
kitSection("Badges",
|
||||
row("flex flex-wrap items-center gap-2",
|
||||
ui.Badge(ui.BadgeProps{Color: ui.BadgeGreen}, Text("active")),
|
||||
ui.Badge(ui.BadgeProps{Color: ui.BadgeRed}, Text("failed")),
|
||||
ui.Badge(ui.BadgeProps{Color: ui.BadgeBlue}, Text("info")),
|
||||
ui.Badge(ui.BadgeProps{Color: ui.BadgeAmber, Pill: true}, Text("pending")),
|
||||
ui.Badge(ui.BadgeProps{Color: ui.BadgeNeutral}, Text("default")),
|
||||
ui.Badge(ui.BadgeProps{Color: ui.BadgeMuted}, Text("muted")),
|
||||
),
|
||||
),
|
||||
|
||||
kitSection("Alerts",
|
||||
ui.Alert(ui.AlertBlue, "Heads up", Text("An informational message with a header.")),
|
||||
ui.Alert(ui.AlertGreen, "", Text("A success alert without a header.")),
|
||||
ui.Alert(ui.AlertYellow, "Warning", Text("Something needs your attention.")),
|
||||
ui.Alert(ui.AlertRed, "Error", Text("Something went wrong.")),
|
||||
),
|
||||
|
||||
kitSection("Toggles & segmented control",
|
||||
ui.ToggleSwitch(notify.Get(), func(v bool) { notify.Set(v) }, "Email notifications", "Send me product updates", false, ""),
|
||||
ui.SegmentedButtons([]ui.SegmentedButtonOption{
|
||||
{Value: "day", Label: "Day"},
|
||||
{Value: "week", Label: "Week"},
|
||||
{Value: "month", Label: "Month"},
|
||||
}, span.Get(), func(v string) { span.Set(v) }, false, "max-w-xs"),
|
||||
),
|
||||
|
||||
kitSection("Tabs",
|
||||
ui.TabGroup(ui.TabGroupProps{
|
||||
Items: []ui.TabItem{
|
||||
{Title: "Overview", Content: P(Attr("class", "pt-3 text-sm text-ink-soft"), Text("The overview panel."))},
|
||||
{Title: "Details", Content: P(Attr("class", "pt-3 text-sm text-ink-soft"), Text("The details panel."))},
|
||||
{Title: "Activity", Badge: 3, Content: P(Attr("class", "pt-3 text-sm text-ink-soft"), Text("The activity panel (3 new)."))},
|
||||
},
|
||||
ActiveIndex: tab.Get(),
|
||||
OnTabChange: func(i int) { tab.Set(i) },
|
||||
}),
|
||||
),
|
||||
|
||||
kitSection("Accordion",
|
||||
ui.SingleAccordion([]ui.AccordionItemData{
|
||||
{Title: "What is Kjol Web?", Content: P(Attr("class", "text-sm text-ink-soft"), Text("kjol's Go→WebAssembly UI engine."))},
|
||||
{Title: "Is it isomorphic?", Content: P(Attr("class", "text-sm text-ink-soft"), Text("Yes — the same Go renders on the server (SSR) and hydrates on the client."))},
|
||||
{Title: "How is it styled?", Content: P(Attr("class", "text-sm text-ink-soft"), Text("Tailwind utility classes, compiled by kjol's native Tailwind engine."))},
|
||||
}, acc.Get(), func(i int) { acc.Set(i) }),
|
||||
),
|
||||
|
||||
kitSection("Forms",
|
||||
row("grid gap-4 sm:grid-cols-3",
|
||||
row("flex flex-col gap-1", ui.FormLabel(ui.FormLabelProps{}, Text("Name")),
|
||||
ui.FormInput(ui.FormInputProps{Value: name.Get(), Placeholder: "Ada Lovelace", OnInput: func(v string) { name.Set(v) }})),
|
||||
row("flex flex-col gap-1", ui.FormLabel(ui.FormLabelProps{}, Text("Email")),
|
||||
ui.FormEmailInput(ui.FormInputProps{Value: email.Get(), Placeholder: "ada@example.com", OnInput: func(v string) { email.Set(v) }}, true)),
|
||||
row("flex flex-col gap-1", ui.FormLabel(ui.FormLabelProps{}, Text("Plan")),
|
||||
ui.FormSelect(ui.FormSelectProps{Value: plan.Get(), OnChange: func(v string) { plan.Set(v) }},
|
||||
ui.FormOption("free", "Free", false),
|
||||
ui.FormOption("pro", "Pro", false),
|
||||
ui.FormOption("enterprise", "Enterprise", false))),
|
||||
|
||||
// A multi-select. Its rows carry checkboxes, and the field shows the
|
||||
// selection as removable pills — until they stop fitting, at which point
|
||||
// it collapses to "N items selected". Tick a few and watch it flip.
|
||||
row("flex flex-col gap-1", ui.FormLabel(ui.FormLabelProps{}, Text("Languages")),
|
||||
skills.Render(ui.FormMultiSelectProps{
|
||||
Options: languageOptions(),
|
||||
Value: langs.Get(),
|
||||
Placeholder: "Pick a few",
|
||||
Searchable: true,
|
||||
ShowSelectAll: true,
|
||||
OnChange: func(v []string) { langs.Set(v) },
|
||||
})),
|
||||
),
|
||||
P(Attr("class", "text-xs text-ink-muted"),
|
||||
Text("Live: name=\""+name.Get()+"\" email=\""+email.Get()+"\" plan=\""+plan.Get()+
|
||||
"\" languages="+strings.Join(langs.Get(), ","))),
|
||||
),
|
||||
|
||||
kitSection("Masked inputs",
|
||||
row("grid gap-4 sm:grid-cols-2",
|
||||
row("flex flex-col gap-1", ui.FormLabel(ui.FormLabelProps{}, Text("Tax ID")),
|
||||
// The mask is a pure function of the string, applied on every keystroke.
|
||||
// It must be idempotent — it is fed its own output — or the field
|
||||
// corrupts itself as you type.
|
||||
ui.FormInput(ui.FormInputProps{
|
||||
Value: taxID.Get(),
|
||||
Placeholder: "12-3456789",
|
||||
OnInput: func(v string) { taxID.Set(ui.MaskTaxID(v)) },
|
||||
})),
|
||||
row("flex flex-col gap-1", ui.FormLabel(ui.FormLabelProps{}, Text("Rate")),
|
||||
ui.FormInput(ui.FormInputProps{
|
||||
Value: rate.Get(),
|
||||
Placeholder: "5.25",
|
||||
OnInput: func(v string) { rate.Set(ui.MaskRate(v)) },
|
||||
})),
|
||||
),
|
||||
P(Attr("class", "text-xs text-ink-muted"),
|
||||
Text("Type letters, extra dots, leading zeros — the mask takes what it can use.")),
|
||||
),
|
||||
|
||||
kitSection("Async combobox",
|
||||
row("max-w-sm",
|
||||
people.Render(ui.FormAsyncComboboxProps{
|
||||
Placeholder: "Search people…",
|
||||
OnSelect: func(o ui.FormSelectOption) { picked.Set(o.Label + " <" + o.Value + ">") },
|
||||
}),
|
||||
),
|
||||
P(Attr("class", "text-xs text-ink-muted"),
|
||||
Text("Two characters before it asks; 200 ms after you stop typing. A response for a "+
|
||||
"query you have already typed past is discarded rather than shown. Picked: "+
|
||||
orElse(picked.Get(), "nothing yet"))),
|
||||
),
|
||||
|
||||
kitSection("Multi-select behind your own trigger",
|
||||
tagPicker.Render(ui.FormMultiSelectTriggerProps{
|
||||
Trigger: ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Small: true,
|
||||
Icon: "filter", Text: "Tags (" + itoa(len(tags.Get())) + ")"}),
|
||||
Options: languageOptions(),
|
||||
Value: tags.Get(),
|
||||
Searchable: true,
|
||||
ShowSelectAll: true,
|
||||
OnChange: func(v []string) { tags.Set(v) },
|
||||
}),
|
||||
P(Attr("class", "text-xs text-ink-muted"),
|
||||
Text("Same selection model as the field above; only the thing you click on differs.")),
|
||||
),
|
||||
|
||||
kitSection("Signature pad",
|
||||
pad.Render(ui.SignaturePadProps{}),
|
||||
P(Attr("class", "text-xs text-ink-muted"),
|
||||
Text("Draw in it. It is an SVG, not a canvas — so the markup you are looking at IS the "+
|
||||
"value the caller gets ("+itoa(len(signed.Get()))+" bytes), and a stored signature "+
|
||||
"renders on the server.")),
|
||||
),
|
||||
|
||||
kitSection("Table",
|
||||
ui.PrettyTable(
|
||||
[]ui.PrettyTableColumn{
|
||||
{DisplayName: "Name"},
|
||||
{DisplayName: "Plan"},
|
||||
{DisplayName: "Status", DisplayPosition: ui.PrettyTableColRight},
|
||||
},
|
||||
ui.PrettyTableOptions{Hover: true, Alternate: true, SurroundingBorder: true, HeaderBorderY: true},
|
||||
ptRow("Ada Lovelace", "Pro", ui.Badge(ui.BadgeProps{Color: ui.BadgeGreen}, Text("active"))),
|
||||
ptRow("Alan Turing", "Free", ui.Badge(ui.BadgeProps{Color: ui.BadgeNeutral}, Text("trial"))),
|
||||
ptRow("Grace Hopper", "Enterprise", ui.Badge(ui.BadgeProps{Color: ui.BadgeBlue}, Text("invited"))),
|
||||
),
|
||||
),
|
||||
|
||||
kitSection("Overlays (measured, portaled)",
|
||||
row("flex flex-wrap items-center gap-4",
|
||||
ui.Button(ui.ButtonProps{Color: ui.ButtonPrimary, Text: "Open modal", OnClick: modal.Open}),
|
||||
|
||||
// The menu measures itself against the viewport: drag the window
|
||||
// narrow, or scroll it to the bottom, and it flips/shifts to stay on
|
||||
// screen. Items close the menu themselves — no callback plumbing.
|
||||
menu.TriggerFunc(ui.MenuTriggerProps{Tag: "div"}, func(open bool) *VNode {
|
||||
caret := " ▾"
|
||||
if open {
|
||||
caret = " ▴"
|
||||
}
|
||||
return ui.Button(ui.ButtonProps{Color: ui.ButtonWhite, Text: "Menu" + caret})
|
||||
}),
|
||||
menu.Content("",
|
||||
menu.Item(ui.MenuItemProps{Icon: "check"}, Text("Profile")),
|
||||
menu.Item(ui.MenuItemProps{}, Text("Settings")),
|
||||
ui.MenuDivider(""),
|
||||
menu.Item(ui.MenuItemProps{}, Text("Sign out")),
|
||||
),
|
||||
|
||||
// The tooltip's arrow tracks the trigger even when the panel gets
|
||||
// shifted away from it near a viewport edge.
|
||||
tip.Render(Span(Text("A measured tooltip — try it near the window edge")),
|
||||
ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Text: "Hover me"})),
|
||||
),
|
||||
modal.Render(ui.ModalProps{
|
||||
Header: H3(Attr("class", "text-lg font-semibold text-text-heading"), Text("Example modal")),
|
||||
},
|
||||
P(Attr("class", "text-ink-soft"),
|
||||
Text("Portaled to document.body, so it is not clipped by any ancestor. Escape closes "+
|
||||
"the topmost modal; the backdrop click closes too.")),
|
||||
),
|
||||
),
|
||||
|
||||
docSection("more", "Where to go next",
|
||||
prose("The floating components on this page — the menu, the tooltip, the modal, the "+
|
||||
"multi-select — are the shallow end. Overlays covers how they are positioned, and what "+
|
||||
"happens when one would open off the edge of the screen."),
|
||||
apiTable(
|
||||
apiRow{"ui.Button / ui.Badge / ui.Alert", "The presentational set. Props structs, no state."},
|
||||
apiRow{"ui.FormInput / FormSelect / FormCombobox", "Inputs. Value in, OnChange out — the caller owns the state."},
|
||||
apiRow{"ui.NewMultiSelect", "A controller: checkboxed rows, pills that collapse to \"N items selected\" when they stop fitting."},
|
||||
apiRow{"ui.Tabs / ui.Accordion / ui.Card", "Layout and disclosure."},
|
||||
apiRow{"ui.RegisterIcon", "Add your own icons. The kit ships a small set; the app brings the rest."},
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const kitSnippet = `// A component is a function taking a props struct.
|
||||
ui.Button(ui.ButtonProps{
|
||||
Color: ui.ButtonPrimary,
|
||||
Icon: "check",
|
||||
Text: "Save",
|
||||
OnClick: func() { toaster.Success("Saved.") },
|
||||
})
|
||||
|
||||
// Inputs are controlled: the caller owns the state.
|
||||
name := NewSignal("")
|
||||
|
||||
ui.FormInput(ui.FormInputProps{
|
||||
Value: name.Get(),
|
||||
OnInput: func(v string) { name.Set(v) }, // a write re-renders
|
||||
})`
|
||||
120
go/cmd/kjol-web/app/landing_test.go
Normal file
120
go/cmd/kjol-web/app/landing_test.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"kjol/vdom"
|
||||
)
|
||||
|
||||
// The landing page's whole claim is that its two panes are ONE function: the live
|
||||
// component on the left, and the HTML string the server sends on the right. If they
|
||||
// could drift, the page would be a lie told in the most embarrassing possible place.
|
||||
//
|
||||
// So: render it, click the button the way the browser would, render again, and check
|
||||
// that BOTH panes moved. A pane rendered from a stale copy of the tree — or from a
|
||||
// second, hand-written one — fails here.
|
||||
func TestLandingPanesShareOneTree(t *testing.T) {
|
||||
page := HomePage(Deps{Path: func() string { return "/" }})
|
||||
|
||||
html := vdom.RenderHTML(page())
|
||||
if !strings.Contains(html, "clicked 0 times") {
|
||||
t.Fatalf("the live pane did not render its initial state:\n%s", html)
|
||||
}
|
||||
// The right-hand pane is the ESCAPED HTML of the same tree, so the markup it shows
|
||||
// appears in the page's own markup double-escaped: <div ...
|
||||
if !strings.Contains(html, "<div class=") {
|
||||
t.Fatal("the right-hand pane is not showing rendered HTML at all")
|
||||
}
|
||||
|
||||
clickButton(t, page(), "Click me")
|
||||
|
||||
html = vdom.RenderHTML(page())
|
||||
if strings.Count(html, "clicked 1 times") < 2 {
|
||||
t.Errorf("after one click, %d panes say \"clicked 1 times\" — both should:\n%s",
|
||||
strings.Count(html, "clicked 1 times"), html)
|
||||
}
|
||||
}
|
||||
|
||||
// The byte count under the right-hand pane is the length of the string actually shown,
|
||||
// not a number typed in by hand — so it has to move when the markup does.
|
||||
func TestLandingByteCountIsReal(t *testing.T) {
|
||||
page := HomePage(Deps{Path: func() string { return "/" }})
|
||||
|
||||
before := byteCountLabel(t, vdom.RenderHTML(page()))
|
||||
clickButton(t, page(), "Click me")
|
||||
// "clicked 0 times" -> "clicked 1 times" is the same length, so click into double
|
||||
// digits, where the markup genuinely grows by one byte.
|
||||
for i := 0; i < 10; i++ {
|
||||
clickButton(t, page(), "Click me")
|
||||
}
|
||||
after := byteCountLabel(t, vdom.RenderHTML(page()))
|
||||
|
||||
if before == after {
|
||||
t.Errorf("the markup grew by a digit but the byte count did not move (%s) — it is not measuring the string", before)
|
||||
}
|
||||
}
|
||||
|
||||
// byteCountLabel pulls the "N bytes of HTML" caption out of the rendered page.
|
||||
func byteCountLabel(t *testing.T, html string) string {
|
||||
t.Helper()
|
||||
i := strings.Index(html, " bytes of HTML")
|
||||
if i < 0 {
|
||||
t.Fatal("no byte-count caption on the landing page")
|
||||
}
|
||||
start := strings.LastIndexByte(html[:i], '>') + 1
|
||||
return html[start : i+len(" bytes of HTML")]
|
||||
}
|
||||
|
||||
// clickButton finds a button by its label and fires its click handler.
|
||||
func clickButton(t *testing.T, n *vdom.VNode, label string) {
|
||||
t.Helper()
|
||||
if !findAndClickButton(n, label) {
|
||||
t.Fatalf("no clickable button labelled %q on the page", label)
|
||||
}
|
||||
}
|
||||
|
||||
func findAndClickButton(n *vdom.VNode, label string) bool {
|
||||
if n == nil {
|
||||
return false
|
||||
}
|
||||
if n.Tag == "button" && strings.Contains(textOf(n), label) {
|
||||
if h := n.Events[vdom.EVENT_CLICK]; h != nil {
|
||||
h(clickEvent{})
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, c := range n.Children {
|
||||
if findAndClickButton(c, label) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func textOf(n *vdom.VNode) string {
|
||||
if n.Tag == "" {
|
||||
return n.Text
|
||||
}
|
||||
var b strings.Builder
|
||||
for _, c := range n.Children {
|
||||
b.WriteString(textOf(c))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// clickEvent is a vdom.Event with no DOM behind it — enough to invoke a handler.
|
||||
type clickEvent struct{}
|
||||
|
||||
func (clickEvent) PreventDefault() {}
|
||||
func (clickEvent) StopPropagation() {}
|
||||
func (clickEvent) Value() string { return "" }
|
||||
func (clickEvent) Checked() bool { return false }
|
||||
func (clickEvent) Key() string { return "" }
|
||||
func (clickEvent) ClientX() int { return 0 }
|
||||
func (clickEvent) ClientY() int { return 0 }
|
||||
func (clickEvent) Target() any { return nil }
|
||||
func (clickEvent) SetData(_, _ string) {}
|
||||
func (clickEvent) GetData(string) string { return "" }
|
||||
|
||||
var _ vdom.Event = clickEvent{}
|
||||
200
go/cmd/kjol-web/app/layers.go
Normal file
200
go/cmd/kjol-web/app/layers.go
Normal file
@@ -0,0 +1,200 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
. "kjol/vdom"
|
||||
ui "kjol/webui"
|
||||
)
|
||||
|
||||
// The layers of kjol, as data.
|
||||
//
|
||||
// This is the Go mirror of frontend/src/layers.ts. The site has two front-ends built
|
||||
// by two completely different pipelines, and the Layers menu has to be identical in
|
||||
// both — so it is a LIST in each, not markup, and the two lists are the only thing
|
||||
// that has to be kept in step.
|
||||
//
|
||||
// (A shared source would be better than a mirrored one. There isn't one: this half
|
||||
// compiles to WebAssembly and the other is bundled by esbuild, and nothing is upstream
|
||||
// of both. Keeping it to a flat slice of plain data is what makes the duplication
|
||||
// survivable — you can diff the two by eye.)
|
||||
|
||||
type Layer struct {
|
||||
Name string
|
||||
Href string
|
||||
Tagline string
|
||||
// Live means you can click into worked examples. The others are documented but
|
||||
// have no demo — they still appear, because a menu that silently omits half the
|
||||
// library teaches the reader that the library is half the size it is.
|
||||
Live bool
|
||||
Icon string
|
||||
}
|
||||
|
||||
func Layers() []Layer {
|
||||
return []Layer{
|
||||
{
|
||||
Name: "Kjol Go",
|
||||
Href: "/go",
|
||||
Tagline: "The server base: config, database, logging, HTTP, mail, validation.",
|
||||
Icon: "server",
|
||||
},
|
||||
{
|
||||
Name: "Kjol Wasm Web",
|
||||
Href: "/wasm",
|
||||
Tagline: "Web interfaces written in Go, compiled to WebAssembly. SSR + hydration, no JS build.",
|
||||
Live: true,
|
||||
Icon: "code",
|
||||
},
|
||||
{
|
||||
Name: "Kjol JS Web",
|
||||
Href: "/js",
|
||||
Tagline: "The Solid component kit, bundled by a Go toolchain: TSX → Solid → esbuild, Tailwind in Go.",
|
||||
Live: true,
|
||||
Icon: "squares",
|
||||
},
|
||||
{
|
||||
Name: "Kjol C",
|
||||
Href: "/c",
|
||||
Tagline: "Arena allocator, strings, math, lexer, platform layer.",
|
||||
Icon: "bolt",
|
||||
},
|
||||
{
|
||||
Name: "Kjol Jai",
|
||||
Href: "/jai",
|
||||
Tagline: "Console rendering module. Early.",
|
||||
Icon: "cube",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// CurrentLayer is the layer the given path belongs to, or nil on the front page.
|
||||
func CurrentLayer(path string) *Layer {
|
||||
for i, l := range Layers() {
|
||||
if path == l.Href || strings.HasPrefix(path, l.Href+"/") {
|
||||
return &Layers()[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LayersMenuCtl is the Layers menu's controller.
|
||||
//
|
||||
// It is created ONCE, here, at package level — not inside layersMenu, which is called
|
||||
// from a layout on every single render. A floating component is a controller: it owns
|
||||
// an open signal, a positioning engine and document listeners, and building a fresh one
|
||||
// per render would leak all three and give you a menu that never opens. Same rule as
|
||||
// Theme, a few lines up in pages.go.
|
||||
var LayersMenuCtl = ui.NewMenu(ui.MenuOptions{Placement: ui.PlacementBottomEnd})
|
||||
|
||||
// layersMenu is the site's primary navigation: kjol is a stack of layers, and this is
|
||||
// how you get from any one of them to any other.
|
||||
//
|
||||
// A layer that is Live is a link. One that is not is inert and dimmed, with the word
|
||||
// "reference" on it — it exists, it is documented in the repository, there is simply
|
||||
// nothing here to click.
|
||||
//
|
||||
// Crossing into another layer is a REAL navigation, not a client-side route: /js is a
|
||||
// different binary's SPA and /wasm is this one. Hence a plain href and no navigate()
|
||||
// interception — an intercepted click would ask this WebAssembly to render a page it
|
||||
// does not have.
|
||||
func layersMenu(d Deps) *VNode {
|
||||
content := []*VNode{
|
||||
P(Attr("class", "px-3 pb-1 pt-2 text-[11px] font-semibold uppercase tracking-widest text-ink-faint"),
|
||||
Text("The layers of kjol")),
|
||||
}
|
||||
for _, l := range Layers() {
|
||||
content = append(content, layerItem(d, l))
|
||||
}
|
||||
|
||||
return Div(Attr("class", "relative"),
|
||||
LayersMenuCtl.Trigger(ui.MenuTriggerProps{
|
||||
Class: "inline-flex items-center gap-1.5 rounded-default px-3 py-1.5 text-sm font-medium text-ink-soft hover:bg-surface-raised hover:text-ink",
|
||||
},
|
||||
Text("Layers"),
|
||||
ui.IconInline("chevron-down", 11, "text-ink-faint"),
|
||||
),
|
||||
LayersMenuCtl.Content("w-96", content...),
|
||||
)
|
||||
}
|
||||
|
||||
// layersGrid is the front page's list of layers — the same data as the menu, laid out
|
||||
// to be read rather than navigated. A layer with no examples still gets a row: the
|
||||
// point of the page is what kjol IS, and half of it having no demo yet does not make
|
||||
// that half not exist.
|
||||
func layersGrid(d Deps) *VNode {
|
||||
rows := []Mod{Attr("class", "mt-5 divide-y divide-line rounded-default border border-line")}
|
||||
for _, l := range Layers() {
|
||||
rows = append(rows, layerRow(l))
|
||||
}
|
||||
return Div(rows...)
|
||||
}
|
||||
|
||||
func layerRow(l Layer) *VNode {
|
||||
head := Span(Attr("class", "flex items-center gap-2"),
|
||||
ui.IconInline(l.Icon, 15, iff(l.Live, "text-accent", "text-ink-muted")),
|
||||
Span(Attr("class", "font-medium text-ink"), Text(l.Name)),
|
||||
iff2(l.Live,
|
||||
func() *VNode { return nil },
|
||||
func() *VNode {
|
||||
return Span(Attr("class", "rounded-full border border-line px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-ink-faint"),
|
||||
Text("reference"))
|
||||
}),
|
||||
)
|
||||
body := P(Attr("class", "mt-1 pl-[23px] text-sm leading-relaxed text-ink-muted"), Text(l.Tagline))
|
||||
|
||||
if !l.Live {
|
||||
return Div(Attr("class", "px-5 py-4 opacity-75"), head, body)
|
||||
}
|
||||
// A real navigation: the next layer is a different binary.
|
||||
return A(Attr("class", "block px-5 py-4 no-underline hover:bg-surface-muted"), Attr("href", l.Href),
|
||||
head, body,
|
||||
Span(Attr("class", "mt-2 inline-flex items-center gap-1.5 pl-[23px] text-sm text-accent"),
|
||||
Text("Read the docs"),
|
||||
ui.IconInline("arrow-right", 12, ""),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// iff picks a string; iff2 picks a node. Go has no ternary, and a four-line if
|
||||
// statement inside a tree literal breaks the shape of the markup worse than these do.
|
||||
func iff(cond bool, a, b string) string {
|
||||
if cond {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func iff2(cond bool, a, b func() *VNode) *VNode {
|
||||
if cond {
|
||||
return a()
|
||||
}
|
||||
return b()
|
||||
}
|
||||
|
||||
func layerItem(d Deps, l Layer) *VNode {
|
||||
active := CurrentLayer(d.Path()) != nil && CurrentLayer(d.Path()).Href == l.Href
|
||||
|
||||
if !l.Live {
|
||||
return Div(Attr("class", "flex cursor-default flex-col gap-0.5 px-3 py-2 opacity-55"),
|
||||
Span(Attr("class", "flex items-center gap-2 text-sm font-medium text-ink-muted"),
|
||||
ui.IconInline(l.Icon, 14, "text-ink-faint"),
|
||||
Text(l.Name),
|
||||
Span(Attr("class", "rounded-full bg-surface-raised px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-ink-muted"),
|
||||
Text("reference")),
|
||||
),
|
||||
Span(Attr("class", "pl-6 text-xs text-ink-muted"), Text(l.Tagline)),
|
||||
)
|
||||
}
|
||||
|
||||
cls := "flex flex-col gap-0.5 px-3 py-2 no-underline hover:bg-surface-raised"
|
||||
if active {
|
||||
cls += " bg-primary-subtle"
|
||||
}
|
||||
return A(Attr("class", cls), Attr("href", l.Href),
|
||||
Span(Attr("class", "flex items-center gap-2 text-sm font-medium text-ink"),
|
||||
ui.IconInline(l.Icon, 14, "text-accent"),
|
||||
Text(l.Name),
|
||||
),
|
||||
Span(Attr("class", "pl-6 text-xs text-ink-muted"), Text(l.Tagline)),
|
||||
)
|
||||
}
|
||||
406
go/cmd/kjol-web/app/overlays.go
Normal file
406
go/cmd/kjol-web/app/overlays.go
Normal file
@@ -0,0 +1,406 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
. "kjol/vdom"
|
||||
ui "kjol/webui"
|
||||
)
|
||||
|
||||
// Every floating component in the kit, on one page: tooltips, popovers, menus and
|
||||
// submenus, the date picker, modals (plain, confirm, wizard, imperative), toasts,
|
||||
// and the tutorial's spotlight coachmarks.
|
||||
//
|
||||
// All of them are CONTROLLERS. They own refs, timers and open state, so they are
|
||||
// created once here — never inside the render closure, which runs on every signal
|
||||
// write and would rebuild them (and their refs) from scratch every frame. That is
|
||||
// the single rule to remember about the floating layer.
|
||||
//
|
||||
// The page is NOT `static`: nothing is open during SSR anyway, so pre-rendering it
|
||||
// buys nothing, and it keeps the example honest about which routes need it.
|
||||
//
|
||||
//gowasm:page /wasm/overlays layout=app
|
||||
func OverlaysPage(d Deps) func() *VNode {
|
||||
// --- tooltips -----------------------------------------------------------
|
||||
tipTop := ui.NewHoverTooltip(ui.PlacementTop, "")
|
||||
tipRight := ui.NewHoverTooltip(ui.PlacementRight, "")
|
||||
tipFocus := ui.NewFocusTooltip(ui.PlacementBottom, "")
|
||||
tipFast := ui.NewTooltip(ui.TooltipProps{Placement: ui.PlacementTop, Delay: -1})
|
||||
|
||||
// --- popovers -----------------------------------------------------------
|
||||
pop := ui.NewPopover(ui.PopoverProps{Placement: ui.PlacementBottomStart})
|
||||
popEnd := ui.NewPopover(ui.PopoverProps{Placement: ui.PlacementBottomEnd})
|
||||
hoverPop := ui.NewHoverPopover(ui.HoverPopoverProps{
|
||||
Placement: ui.PlacementTop,
|
||||
// The bridge: the cursor gets 300ms of grace to cross the gap from the
|
||||
// trigger onto the panel. Without it, the panel closes in the dead space
|
||||
// between them — which is exactly what happens once a panel is portaled and
|
||||
// CSS :hover no longer reaches it.
|
||||
HoverCloseDelay: 300,
|
||||
})
|
||||
|
||||
// --- menus --------------------------------------------------------------
|
||||
menu := ui.NewMenu(ui.MenuOptions{Placement: ui.PlacementBottomStart})
|
||||
sub := ui.NewSubmenu(menu)
|
||||
hoverMenu := ui.NewMenu(ui.MenuOptions{Placement: ui.PlacementBottomStart, OpenOnHover: true})
|
||||
|
||||
// --- date pickers -------------------------------------------------------
|
||||
picked := NewSignal("")
|
||||
dp := ui.NewDatePicker(ui.DatePickerProps{
|
||||
Placeholder: "Pick a date",
|
||||
Clearable: true,
|
||||
OnChange: func(v string) { picked.Set(v) },
|
||||
})
|
||||
dob := ui.NewDateOfBirthPicker(ui.DatePickerProps{Placeholder: "Date of birth"})
|
||||
|
||||
// --- modals -------------------------------------------------------------
|
||||
modal := ui.NewModal(ui.ModalOptions{Size: ui.ModalMedium})
|
||||
nested := ui.NewModal(ui.ModalOptions{Size: ui.ModalSmall})
|
||||
deleted := NewSignal(false)
|
||||
confirm := ui.NewModal(ui.ModalOptions{})
|
||||
|
||||
// --- wizard -------------------------------------------------------------
|
||||
wizardName := NewSignal("")
|
||||
wizardDone := NewSignal(false)
|
||||
wizard := ui.NewWizard(ui.ModalOptions{})
|
||||
|
||||
// --- toasts -------------------------------------------------------------
|
||||
// The Toaster owns the queue AND the clocks: it generates IDs, runs the
|
||||
// auto-dismiss timer, and animates the countdown bar down to zero. (ToastProvider,
|
||||
// the dumb half, renders a list you hand it and removes nothing — a toast pushed
|
||||
// through it stays until you take it away yourself.)
|
||||
toaster := ui.NewToaster(ui.ToasterOptions{Position: ui.ToastBottomRight})
|
||||
pushToast := func(kind ui.ToastType, msg string) {
|
||||
toaster.Push(ui.Toast{Message: msg, Type: kind})
|
||||
}
|
||||
|
||||
// --- tutorial -----------------------------------------------------------
|
||||
// Steps target elements by CSS SELECTOR. The tour resolves each one with
|
||||
// document.querySelector, measures it, scrolls it into view, and cuts a hole in
|
||||
// the dimmed overlay around it — the spotlight animates from target to target.
|
||||
tour := ui.NewTutorial(ui.TutorialOptions{
|
||||
Steps: []ui.TutorialStep{
|
||||
{
|
||||
Title: "Tooltips",
|
||||
Target: "#demo-tooltips",
|
||||
Content: func() *VNode { return Text("Measured, portaled, and they flip near a viewport edge.") },
|
||||
},
|
||||
{
|
||||
Title: "Popovers",
|
||||
Target: "#demo-popovers",
|
||||
Placement: ui.PlacementBottom,
|
||||
Content: func() *VNode { return Text("Click or hover. The hover bridge lets you reach the panel.") },
|
||||
},
|
||||
{
|
||||
Title: "Menus",
|
||||
Target: "#demo-menus",
|
||||
Content: func() *VNode { return Text("Items close the menu themselves; submenus are portaled.") },
|
||||
},
|
||||
{
|
||||
// No Target: the page dims flat and the card centres in the viewport.
|
||||
Title: "That's the tour",
|
||||
Content: func() *VNode { return Text("Escape ends it. Arrow keys and Enter move between steps.") },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return func() *VNode {
|
||||
return docPage("Components", "Overlays",
|
||||
"Tooltips, popovers, menus, modals and toasts — every one of them measured against the real "+
|
||||
"viewport. A floating panel is portaled to document.body, positioned from its trigger's "+
|
||||
"bounding box, and flipped or shifted when it would otherwise run off the screen.",
|
||||
|
||||
docSection("engine", "How a panel is placed",
|
||||
prose("Positioning is a pure function: given the trigger's rectangle, the panel's size and the "+
|
||||
"viewport, it returns coordinates. It is unit-tested natively, with no browser in sight, "+
|
||||
"because none of it is about the browser — the browser only supplies the three rectangles."),
|
||||
prose("The result is written to the element with SetStyle, NOT through a signal. A signal write "+
|
||||
"re-renders the whole tree, and this runs on every scroll and resize frame; going through "+
|
||||
"the vdom would rebuild the page sixty times a second to move one panel four pixels."),
|
||||
code("webui/floating.go", floatingSnippet),
|
||||
note("Controllers are built once",
|
||||
"A floating component owns refs, timers and its open state. Build it alongside your signals, "+
|
||||
"never inside the render closure — one built per frame can never stay open, because the "+
|
||||
"thing holding \"open\" is thrown away and replaced before you can see it."),
|
||||
row("mt-4 flex gap-2", tour.StartButton(0, "", Text("Take the tour"))),
|
||||
),
|
||||
|
||||
// ---- tooltips ----
|
||||
docSection("demo-tooltips", "Tooltips",
|
||||
prose("Hover, or focus — a tooltip that only answers to a mouse is a tooltip a keyboard user "+
|
||||
"cannot read. Narrow the window and hover the Right one: it flips to the left, and its "+
|
||||
"arrow follows it. Near an edge the panel shifts back on screen and the arrow slides to "+
|
||||
"keep pointing at the trigger; in the original kit the arrow detached and pointed at "+
|
||||
"nothing."),
|
||||
demo("Placement, delay, and focus triggers",
|
||||
row("flex flex-wrap items-center gap-3",
|
||||
tipTop.Render(Span(Text("Above — the default")),
|
||||
ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Text: "Top"})),
|
||||
tipRight.Render(Span(Text("To the right, unless it would run off the edge")),
|
||||
ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Text: "Right"})),
|
||||
tipFast.Render(Span(Text("No open delay")),
|
||||
ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Text: "Instant"})),
|
||||
tipFocus.Render(Span(Text("Shown on focus, not hover — tab to the field")),
|
||||
ui.FormInput(ui.FormInputProps{Placeholder: "Focus me"})),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// ---- popovers ----
|
||||
docSection("demo-popovers", "Popovers",
|
||||
prose("A popover closes on an outside click or on Escape — and only the TOPMOST one closes per "+
|
||||
"press, so a dropdown inside a popover does not take the popover down with it. The hover "+
|
||||
"variant keeps a bridge across the gap between trigger and panel, so the cursor can "+
|
||||
"actually reach the thing it opened."),
|
||||
demo("Click, alignment, and hover-with-a-bridge",
|
||||
row("flex flex-wrap items-center gap-3",
|
||||
pop.Trigger(ui.PopoverTriggerProps{},
|
||||
ui.Button(ui.ButtonProps{Color: ui.ButtonPrimary, Text: "Click me"})),
|
||||
pop.Content(ui.PopoverContentProps{Class: "w-64"},
|
||||
P(Attr("class", "text-sm text-ink-soft"),
|
||||
Text("Click outside, or press Escape, to close. Only the topmost floating closes per press.")),
|
||||
),
|
||||
|
||||
popEnd.Trigger(ui.PopoverTriggerProps{},
|
||||
ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Text: "Aligned to my right edge"})),
|
||||
popEnd.Content(ui.PopoverContentProps{Class: "w-56"},
|
||||
P(Attr("class", "text-sm text-ink-soft"), Text("Placement bottom-end.")),
|
||||
),
|
||||
|
||||
hoverPop.Trigger(ui.PopoverTriggerProps{},
|
||||
ui.Button(ui.ButtonProps{Color: ui.ButtonBlue, Outline: true, Text: "Hover me, then reach the panel"})),
|
||||
hoverPop.Content(ui.PopoverContentProps{Class: "w-64"},
|
||||
P(Attr("class", "text-sm text-ink-soft"),
|
||||
Text("Move the cursor across the gap and onto this panel — it stays open. "+
|
||||
"Select this text to prove it.")),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// ---- menus ----
|
||||
docSection("demo-menus", "Menus & submenus",
|
||||
prose("Opening one menu closes the other: a single-open manager keeps the page from filling up "+
|
||||
"with panels nobody asked for. Submenus are exempt from it — they are Standalone — or a "+
|
||||
"submenu would close the very menu it belongs to as it opened."),
|
||||
prose("A submenu is portaled too, which is not a detail: the parent menu scrolls its own "+
|
||||
"contents, and a submenu rendered inside it was clipped by that overflow the moment it "+
|
||||
"was taller than its parent."),
|
||||
demo("Items, icons, a submenu, and KeepOpen",
|
||||
row("flex flex-wrap items-center gap-3",
|
||||
menu.TriggerFunc(ui.MenuTriggerProps{Tag: "div"}, func(open bool) *VNode {
|
||||
caret := " ▾"
|
||||
if open {
|
||||
caret = " ▴"
|
||||
}
|
||||
return ui.Button(ui.ButtonProps{Color: ui.ButtonWhite, Text: "Actions" + caret})
|
||||
}),
|
||||
menu.Content("",
|
||||
menu.Item(ui.MenuItemProps{Icon: "check", OnClick: func() { pushToast(ui.ToastSuccess, "Profile opened") }},
|
||||
Text("Profile")),
|
||||
menu.Item(ui.MenuItemProps{OnClick: func() { pushToast(ui.ToastInfo, "Settings opened") }},
|
||||
Text("Settings")),
|
||||
|
||||
// The submenu is portaled — it used to be clipped by the parent
|
||||
// menu's own overflow-y-auto.
|
||||
sub.Submenu(ui.SubmenuProps{Trigger: "More", Icon: "ellipsis"},
|
||||
sub.Item(ui.MenuItemProps{OnClick: func() { pushToast(ui.ToastInfo, "Archived") }}, Text("Archive")),
|
||||
sub.Item(ui.MenuItemProps{OnClick: func() { pushToast(ui.ToastWarning, "Duplicated") }}, Text("Duplicate")),
|
||||
),
|
||||
|
||||
ui.MenuDivider(""),
|
||||
// KeepOpen is the TSX's closeOnClick inverted: by default an item
|
||||
// closes the menu, which the first Go port dropped entirely.
|
||||
menu.Item(ui.MenuItemProps{KeepOpen: true, OnClick: func() { pushToast(ui.ToastGeneric, "Menu stayed open") }},
|
||||
Text("Stay open (KeepOpen)")),
|
||||
menu.Item(ui.MenuItemProps{Icon: "arrow-right-from-bracket",
|
||||
OnClick: func() { pushToast(ui.ToastError, "Signed out") }}, Text("Sign out")),
|
||||
),
|
||||
|
||||
hoverMenu.TriggerFunc(ui.MenuTriggerProps{Tag: "div"}, func(bool) *VNode {
|
||||
return ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Text: "Opens on hover"})
|
||||
}),
|
||||
hoverMenu.Content("",
|
||||
hoverMenu.Item(ui.MenuItemProps{}, Text("One")),
|
||||
hoverMenu.Item(ui.MenuItemProps{}, Text("Two")),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// ---- date pickers ----
|
||||
docSection("demo-dates", "Date picker",
|
||||
prose("The field is typeable, not merely clickable. It parses loosely — 7/4/26, Jul 4 2026 and "+
|
||||
"2026-07-04 all work — and commits what it understood on blur, so the calendar is an "+
|
||||
"affordance rather than the only way in."),
|
||||
demo("Picked: \""+picked.Get()+"\"",
|
||||
row("grid gap-4 sm:grid-cols-2",
|
||||
row("flex flex-col gap-1",
|
||||
ui.FormLabel(ui.FormLabelProps{}, Text("Date (portaled, flips near the bottom)")),
|
||||
dp.Render(),
|
||||
),
|
||||
row("flex flex-col gap-1",
|
||||
ui.FormLabel(ui.FormLabelProps{}, Text("Date of birth (inline, three selects)")),
|
||||
dob.Render(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// ---- modals ----
|
||||
docSection("demo-modals", "Modals",
|
||||
prose("Portaled to document.body, so no ancestor's overflow:hidden or transform can clip them. "+
|
||||
"Open the modal, then the nested one inside it, and press Escape twice: modals unwind one "+
|
||||
"layer per press rather than all at once."),
|
||||
prose("The last button opens a modal that no component in the tree owns — webui.OpenModal hands "+
|
||||
"content to a shared host rendered once in the layout. That is what code far from the view "+
|
||||
"needs: a confirmation raised from inside a save handler, say."),
|
||||
demo("Deleted: "+strconv.FormatBool(deleted.Get()),
|
||||
row("flex flex-wrap items-center gap-3",
|
||||
ui.Button(ui.ButtonProps{Color: ui.ButtonPrimary, Text: "Open modal", OnClick: modal.Open}),
|
||||
ui.Button(ui.ButtonProps{Color: ui.ButtonRed, Outline: true, Text: "Delete something…", OnClick: confirm.Open}),
|
||||
ui.Button(ui.ButtonProps{Color: ui.ButtonBlue, Outline: true, Text: "Open wizard", OnClick: wizard.Open}),
|
||||
ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Text: "Open imperatively",
|
||||
OnClick: func() {
|
||||
// No component in the tree owns this one: OpenModal hands content
|
||||
// to the shared host rendered in the layout.
|
||||
ui.OpenModal(func() *VNode {
|
||||
return ui.ModalContent(ui.ModalContentProps{
|
||||
Header: H3(Attr("class", "text-lg font-semibold text-text-heading"), Text("Opened from anywhere")),
|
||||
},
|
||||
P(Attr("class", "text-ink-soft"),
|
||||
Text("This content was not rendered by any component — it was handed to "+
|
||||
"ModalHost (see AppLayout) by webui.OpenModal.")),
|
||||
)
|
||||
}, ui.ModalOptions{Size: ui.ModalSmall})
|
||||
}}),
|
||||
),
|
||||
),
|
||||
|
||||
// The modals themselves. They portal to document.body, so where they sit in
|
||||
// the tree makes no difference to where they appear.
|
||||
modal.Render(ui.ModalProps{
|
||||
Header: H3(Attr("class", "text-lg font-semibold text-text-heading"), Text("A modal")),
|
||||
Footer: ui.Button(ui.ButtonProps{Color: ui.ButtonNeutral, Text: "Close", OnClick: modal.Close}),
|
||||
},
|
||||
P(Attr("class", "text-ink-soft"),
|
||||
Text("Portaled to document.body, so no ancestor's overflow:hidden can clip it. It fades "+
|
||||
"and scales in — a double requestAnimationFrame, because a single frame does not "+
|
||||
"give the browser time to commit the initial style.")),
|
||||
row("mt-4",
|
||||
ui.Button(ui.ButtonProps{Color: ui.ButtonBlue, Outline: true, Text: "Open a nested modal", OnClick: nested.Open}),
|
||||
),
|
||||
),
|
||||
nested.Render(ui.ModalProps{
|
||||
Header: H3(Attr("class", "text-lg font-semibold text-text-heading"), Text("Nested")),
|
||||
},
|
||||
P(Attr("class", "text-ink-soft"), Text("Escape closes THIS one first, not the one behind it.")),
|
||||
),
|
||||
confirm.Confirm(ui.ConfirmModalProps{
|
||||
Title: "Delete row",
|
||||
Message: "This cannot be undone.",
|
||||
OnConfirm: func() {
|
||||
deleted.Set(true)
|
||||
pushToast(ui.ToastError, "Row deleted")
|
||||
},
|
||||
}),
|
||||
wizard.Render(ui.WizardProps{
|
||||
Title: "Set up your account",
|
||||
FinishText: "Finish",
|
||||
OnComplete: func() {
|
||||
wizardDone.Set(true)
|
||||
pushToast(ui.ToastSuccess, "Wizard complete: "+wizardName.Get())
|
||||
},
|
||||
Steps: []ui.WizardStep{
|
||||
{
|
||||
Title: "Your name",
|
||||
// Each step gets its own context: SetCanContinue gates THIS step's
|
||||
// Next button, which a single shared bool could not express.
|
||||
Content: func(ctx ui.WizardStepContext) *VNode {
|
||||
ctx.SetCanContinue(wizardName.Get() != "")
|
||||
return row("flex flex-col gap-1",
|
||||
ui.FormLabel(ui.FormLabelProps{}, Text("Name (required to continue)")),
|
||||
ui.FormInput(ui.FormInputProps{
|
||||
Value: wizardName.Get(),
|
||||
Placeholder: "Ada Lovelace",
|
||||
OnInput: func(v string) { wizardName.Set(v) },
|
||||
}),
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
Title: "Confirm",
|
||||
Content: func(ctx ui.WizardStepContext) *VNode {
|
||||
ctx.SetCanContinue(true)
|
||||
return P(Attr("class", "text-ink-soft"),
|
||||
Text("All set for "+wizardName.Get()+". Finish to close."))
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
|
||||
// ---- toasts ----
|
||||
docSection("demo-toasts", "Toasts",
|
||||
prose("They dismiss themselves after five seconds. Watch the bar count down: it is one CSS "+
|
||||
"transition, written straight at the element — not a re-render per frame, which is what a "+
|
||||
"progress bar driven through a signal would cost you."),
|
||||
prose("A sticky toast (Duration: ToastSticky) waits for the user instead. The menu items above "+
|
||||
"raise toasts too, which is how you can see that an item really does close its own menu."),
|
||||
demo("Push, dismiss, and a sticky one",
|
||||
row("flex flex-wrap items-center gap-2",
|
||||
ui.Button(ui.ButtonProps{Color: ui.ButtonGreen, Small: true, Text: "Success",
|
||||
OnClick: func() { toaster.Success("Saved.") }}),
|
||||
ui.Button(ui.ButtonProps{Color: ui.ButtonRed, Small: true, Text: "Error",
|
||||
OnClick: func() { toaster.Error("Something went wrong.") }}),
|
||||
ui.Button(ui.ButtonProps{Color: ui.ButtonBlue, Small: true, Text: "Info",
|
||||
OnClick: func() { toaster.Info("Just so you know.") }}),
|
||||
ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Small: true, Text: "Sticky (no timer)",
|
||||
OnClick: func() {
|
||||
toaster.Push(ui.Toast{
|
||||
Message: "This one waits for you to dismiss it.",
|
||||
Type: ui.ToastWarning,
|
||||
Duration: ui.ToastSticky,
|
||||
})
|
||||
}}),
|
||||
ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Small: true, Text: "Clear all",
|
||||
OnClick: toaster.Clear}),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
docSection("overlay-api", "Reference",
|
||||
apiTable(
|
||||
apiRow{"NewFloating", "The positioning engine behind every panel: placement, offset, flip, shift, arrow."},
|
||||
apiRow{"NewTooltip / NewPopover / NewMenu", "Controllers. Build once, outside the render."},
|
||||
apiRow{"Standalone", "Exempts a panel from the single-open manager. A submenu needs it, or it closes its own parent."},
|
||||
apiRow{"vdom.Portal", "Mounts children at document.body — the escape hatch from an ancestor's overflow:hidden."},
|
||||
apiRow{"webui.OpenModal / ModalHost", "Open a modal from code that owns no component. Render the host once, in your layout."},
|
||||
),
|
||||
),
|
||||
|
||||
// The toast container and the tutorial's overlay both render here; both are
|
||||
// fixed-position, so where they sit in the tree does not matter.
|
||||
toaster.Render(),
|
||||
tour.Render(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const floatingSnippet = `// Built ONCE — it owns refs, timers, and whether it is open.
|
||||
pop := ui.NewPopover(ui.PopoverOptions{
|
||||
Placement: ui.PlacementBottomStart,
|
||||
Offset: 8,
|
||||
})
|
||||
|
||||
// ...and in the render:
|
||||
pop.Trigger(ui.PopoverTriggerProps{},
|
||||
ui.Button(ui.ButtonProps{Text: "Click me"}),
|
||||
)
|
||||
pop.Content(ui.PopoverContentProps{Class: "w-64"},
|
||||
P(Text("Outside click and Escape close me.")),
|
||||
)
|
||||
|
||||
// The panel is portaled to document.body and positioned imperatively:
|
||||
// render invisible -> AfterRender -> measure -> ComputePosition -> SetStyle -> reveal
|
||||
// Never through a signal: this runs on every scroll frame.`
|
||||
527
go/cmd/kjol-web/app/pages.go
Normal file
527
go/cmd/kjol-web/app/pages.go
Normal file
@@ -0,0 +1,527 @@
|
||||
// Package app holds the kjol-web site's Go/WASM pages and components as
|
||||
// standalone, platform-neutral functions (SSR on the server, hydrate on the
|
||||
// client). UI is built from the kjol webui kit + Tailwind utility classes.
|
||||
//
|
||||
// Directives (processed by kjol/cmd/wasmgen at build time):
|
||||
//
|
||||
// //gowasm:page <path> [static] [layout=<name>] a route (static => SSR'd)
|
||||
// //gowasm:layout <name> a func(Deps, *VNode) *VNode wrapper
|
||||
// //gowasm:server (see server_counter.go) a server component
|
||||
package app
|
||||
|
||||
//go:generate go run kjol/cmd/wasmgen .
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
. "kjol/vdom"
|
||||
|
||||
// The host API is dual-build — real under js/wasm, no-ops natively — so neutral page
|
||||
// code can measure the browser and still server-render. The landing page uses it for
|
||||
// exactly one thing: reading the clock when hydration commits.
|
||||
"kjol/wasmruntime"
|
||||
ui "kjol/webui"
|
||||
)
|
||||
|
||||
// Deps are the client-only capabilities, injected so pages stay neutral.
|
||||
type Deps struct {
|
||||
Path func() string
|
||||
Navigate func(string)
|
||||
}
|
||||
|
||||
// Theme is the site-wide theme controller. One per site, created once — the switch in
|
||||
// the header and the class on <html> have to be the same object, or the button and the
|
||||
// page disagree about what theme you are in.
|
||||
//
|
||||
// The client calls Theme.Init() after mounting (see wasm/main.go); on the server it is
|
||||
// inert, and the document's boot script has already put the right class on <html>.
|
||||
var Theme = ui.NewTheme()
|
||||
|
||||
func itoa(n int) string { return strconv.Itoa(n) }
|
||||
|
||||
// Layout wraps a page's content with shared chrome (declared with //gowasm:layout,
|
||||
// selected per route via `layout=`; the generated LayoutFor dispatches by name).
|
||||
type Layout func(d Deps, content *VNode) *VNode
|
||||
|
||||
// Shell renders the current route's page inside its declared layout.
|
||||
func Shell(d Deps, routes map[string]func() *VNode) *VNode {
|
||||
path := d.Path()
|
||||
var content *VNode
|
||||
if page := routes[path]; page != nil {
|
||||
content = page()
|
||||
} else {
|
||||
content = notFound(path)
|
||||
}
|
||||
return LayoutFor(d, path, content)
|
||||
}
|
||||
|
||||
func notFound(path string) *VNode {
|
||||
return Div(Attr("class", "py-10"),
|
||||
H2(Attr("class", "text-xl font-semibold text-ink mb-2"), Text("Page not found")),
|
||||
P(Attr("class", "text-ink-muted"), Text("No route matches "+path+".")),
|
||||
)
|
||||
}
|
||||
|
||||
// --- layouts (Tailwind chrome) -------------------------------------------
|
||||
|
||||
// wordmark is the brand lockup, shared by both layouts so they cannot drift.
|
||||
//
|
||||
// The boat is the point of the name: kjol is Norwegian for KEEL — the spine of a hull,
|
||||
// the thing every other part is built onto. Which is what this library is meant to be
|
||||
// for the applications that share it.
|
||||
func wordmark(d Deps, href string) *VNode {
|
||||
// The lockup names the LAYER you are standing in, not the site. On the front page
|
||||
// that is kjol itself; inside /wasm it is Kjol Wasm Web. A wordmark that says the
|
||||
// same thing everywhere is one more thing the reader has to keep track of himself.
|
||||
name, sub := "kjol", "a shared base layer"
|
||||
if l := CurrentLayer(d.Path()); l != nil {
|
||||
name, sub = l.Name, "Go + WebAssembly"
|
||||
}
|
||||
|
||||
return A(Attr("class", "flex items-center gap-2.5 no-underline"), Attr("href", href), navigate(d, href),
|
||||
Span(Attr("class", "inline-flex h-8 w-8 items-center justify-center rounded-default bg-ink text-surface"),
|
||||
ui.IconInline("sailboat", 17, "")),
|
||||
Span(Attr("class", "flex items-baseline gap-1.5"),
|
||||
Span(Attr("class", "text-lg font-semibold tracking-tight text-text-heading"), Text(name)),
|
||||
Span(Attr("class", "text-sm text-ink-faint"), Text(sub)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// PublicLayout is deliberately plain: a line of navigation, a column of content, a line
|
||||
// of footer. No hero, no glow, no full-bleed anything.
|
||||
//
|
||||
// The grid stays, faintly, because it is the one piece of decoration that is not trying
|
||||
// to sell you something — it is texture, and it costs nothing to read past.
|
||||
//
|
||||
//gowasm:layout public
|
||||
func PublicLayout(d Deps, content *VNode) *VNode {
|
||||
return Div(Attr("class", "relative min-h-screen"),
|
||||
// Behind everything, masked to fade out down the page. aria-hidden +
|
||||
// pointer-events-none because it is decoration: not tabbable, not clickable, not
|
||||
// read aloud.
|
||||
Div(Attr("class", "pointer-events-none fixed inset-0 -z-10 bg-grid grid-fade"), Attr("aria-hidden", "true")),
|
||||
|
||||
Nav(Attr("class", "site-nav border-b border-line"),
|
||||
Div(Attr("class", "mx-auto flex max-w-3xl items-center gap-2 px-4 py-4"),
|
||||
wordmark(d, "/"),
|
||||
Div(Attr("class", "ml-auto flex items-center gap-1"),
|
||||
layersMenu(d),
|
||||
Ul(Attr("class", "flex items-center gap-1"),
|
||||
navItem(d, "/about", "About", false),
|
||||
Li(Attr("class", "ml-1"), Theme.ThemeToggle(ui.ThemeToggleProps{Small: true})),
|
||||
),
|
||||
))),
|
||||
|
||||
Main(Attr("class", "px-4 py-14"), content),
|
||||
|
||||
Footer(Attr("class", "mx-auto max-w-2xl px-4 pb-14"),
|
||||
P(Attr("class", "text-sm text-ink-faint"),
|
||||
Text("kjol is a shared base layer, factored out of several applications so they stay in sync. It is Norwegian for keel.")),
|
||||
),
|
||||
ui.ModalHost(),
|
||||
)
|
||||
}
|
||||
|
||||
// wideRoutes get a roomier container. A table with a dozen columns, a drag handle
|
||||
// and three calculated columns has no business being squeezed into a reading-width
|
||||
// column; prose pages still are.
|
||||
var wideRoutes = map[string]bool{"/wasm/table": true}
|
||||
|
||||
// AppLayout is the DOCUMENTATION shell: a sidebar of sections on the left, the page on
|
||||
// the right. The app routes are the framework's docs — each one explains a capability,
|
||||
// shows the Go that implements it, and then runs that Go on the page — so they are
|
||||
// framed like documentation rather than like a demo carousel.
|
||||
//
|
||||
//gowasm:layout app
|
||||
func AppLayout(d Deps, content *VNode) *VNode {
|
||||
// The content column is wide, and the PROSE inside it is what gets held to a reading
|
||||
// measure (see prose()). Constraining the whole column to reading width instead left
|
||||
// code blocks, demos and reference tables cramped into a third of the screen with a
|
||||
// desert to the right of them — the text was comfortable and everything else paid
|
||||
// for it.
|
||||
width := "max-w-6xl"
|
||||
if wideRoutes[d.Path()] {
|
||||
// The table's own chrome is the demo; a measure would hide the column management
|
||||
// that is the whole point of it.
|
||||
width = "max-w-none"
|
||||
}
|
||||
|
||||
return Div(Attr("class", "min-h-screen bg-surface"),
|
||||
Nav(Attr("class", "app-nav sticky top-0 z-20 border-b border-line bg-surface/90 backdrop-blur"),
|
||||
Div(Attr("class", "mx-auto flex max-w-[110rem] items-center gap-3 px-6 py-3"),
|
||||
wordmark(d, "/"),
|
||||
Span(Attr("class", "rounded-full border border-line px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wider text-ink-faint"), Text("Docs")),
|
||||
Div(Attr("class", "ml-auto flex items-center gap-2"),
|
||||
layersMenu(d),
|
||||
Ul(Attr("class", "flex items-center gap-2"),
|
||||
navItem(d, "/", "Home", false),
|
||||
Li(Theme.ThemeToggle(ui.ThemeToggleProps{Small: true})),
|
||||
),
|
||||
),
|
||||
)),
|
||||
|
||||
Div(Attr("class", "mx-auto flex max-w-[110rem] gap-8 px-6"),
|
||||
docsSidebar(d),
|
||||
Main(Attr("class", "min-w-0 flex-1 py-10"),
|
||||
Div(Attr("class", width), content),
|
||||
),
|
||||
),
|
||||
|
||||
// The host for webui.OpenModal — content opened imperatively, by code that
|
||||
// owns no component in the tree, is portaled out of here. Render it ONCE,
|
||||
// near the root. It is an empty portal when nothing is open.
|
||||
ui.ModalHost(),
|
||||
)
|
||||
}
|
||||
|
||||
// docsSidebar is the section list. Sticky, so it stays put while a long page scrolls —
|
||||
// on a documentation site the nav is how you know where you are, and a nav that scrolls
|
||||
// away leaves you nowhere.
|
||||
func docsSidebar(d Deps) *VNode {
|
||||
mods := []Mod{Attr("class", "sticky top-[3.75rem] hidden h-[calc(100vh-3.75rem)] w-56 shrink-0 overflow-y-auto py-10 lg:block")}
|
||||
for _, g := range docsNav() {
|
||||
items := []Mod{Attr("class", "mt-2 space-y-0.5")}
|
||||
for _, it := range g.Items {
|
||||
items = append(items, Li(sidebarLink(d, it)))
|
||||
}
|
||||
mods = append(mods,
|
||||
Div(Attr("class", "mb-6"),
|
||||
P(Attr("class", "px-2 text-[11px] font-semibold uppercase tracking-widest text-ink-faint"), Text(g.Title)),
|
||||
Ul(items...),
|
||||
),
|
||||
)
|
||||
}
|
||||
return El("aside", mods...)
|
||||
}
|
||||
|
||||
func sidebarLink(d Deps, it docsItem) *VNode {
|
||||
cls := "flex items-center gap-2 rounded-default px-2 py-1.5 text-sm no-underline text-ink-soft hover:bg-surface-raised hover:text-ink"
|
||||
iconCls := "text-ink-faint"
|
||||
if d.Path() == it.Path {
|
||||
cls = "active flex items-center gap-2 rounded-default px-2 py-1.5 text-sm no-underline bg-primary-subtle font-medium text-accent"
|
||||
iconCls = "text-accent"
|
||||
}
|
||||
return A(Attr("class", cls), Attr("href", it.Path), navigate(d, it.Path),
|
||||
ui.IconInline(it.Icon, 14, iconCls),
|
||||
Text(it.Label),
|
||||
)
|
||||
}
|
||||
|
||||
// navItem is a nav link with an active state; dark switches to on-dark colors.
|
||||
func navItem(d Deps, path, label string, dark bool) *VNode {
|
||||
active := d.Path() == path
|
||||
var cls string
|
||||
switch {
|
||||
case dark && active:
|
||||
cls = "active rounded-default px-3 py-1.5 text-sm font-medium bg-white/10 text-white"
|
||||
case dark:
|
||||
cls = "rounded-default px-3 py-1.5 text-sm font-medium text-ink-faint hover:bg-white/5 hover:text-white"
|
||||
case active:
|
||||
cls = "active rounded-default px-3 py-1.5 text-sm font-medium bg-surface-raised text-ink"
|
||||
default:
|
||||
cls = "rounded-default px-3 py-1.5 text-sm font-medium text-ink-soft hover:bg-surface-raised hover:text-ink"
|
||||
}
|
||||
return Li(A(Attr("class", cls+" no-underline"), Attr("href", path), navigate(d, path), Text(label)))
|
||||
}
|
||||
|
||||
// navigate intercepts a link click for client-side SPA navigation (Navigate is
|
||||
// nil on the server, so the anchor falls back to a normal navigation).
|
||||
func navigate(d Deps, path string) Mod {
|
||||
return OnEvent(EVENT_CLICK, func(e Event) {
|
||||
if d.Navigate != nil {
|
||||
e.PreventDefault()
|
||||
d.Navigate(path)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Counter is a presentational client component; state is owned by the caller.
|
||||
func Counter(label string, count *Signal[int]) *VNode {
|
||||
return Div(Attr("class", "counter flex items-center gap-3 rounded-default border border-line bg-surface px-4 py-3 shadow-xs"),
|
||||
Span(Attr("class", "font-medium text-ink-soft"), Text(label+": ")),
|
||||
Strong(Attr("class", "badge inline-flex min-w-8 items-center justify-center rounded-full bg-primary px-2.5 py-0.5 text-sm font-semibold text-white"), Text(itoa(count.Get()))),
|
||||
Div(Attr("class", "ml-auto flex gap-1"),
|
||||
ui.Button(ui.ButtonProps{Color: ui.ButtonSecondary, Small: true, Text: "−", OnClick: func() { count.Update(func(v int) int { return v - 1 }) }}),
|
||||
ui.Button(ui.ButtonProps{Color: ui.ButtonPrimary, Small: true, Text: "+", OnClick: func() { count.Update(func(v int) int { return v + 1 }) }}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// ---- landing ------------------------------------------------------------
|
||||
|
||||
// The landing page is one narrow column of plain text, a demo, and a list.
|
||||
//
|
||||
// It used to be a framework marketing page: an oversized headline, a hero glow, feature
|
||||
// cards in a grid, numbered chapters, a call to action repeated at both ends. All of it
|
||||
// was arguing. None of it was showing. A library this small does not need to argue — it
|
||||
// needs to say what it is, show that it works, and get out of the way, and a reader who
|
||||
// wants to be convinced can click into the docs and find every page running the code it
|
||||
// documents.
|
||||
//
|
||||
// What survives is the part that could not be faked: the same Go function rendered twice
|
||||
// at once, as live DOM and as the HTML string the server sends.
|
||||
//
|
||||
//gowasm:page / static layout=public
|
||||
func HomePage(d Deps) func() *VNode {
|
||||
clicks := NewSignal(0)
|
||||
|
||||
// The one measurement on the page: performance.now() when the client's first render
|
||||
// commits. Zero until then — which is what the SERVER renders, and what the client
|
||||
// renders on its first pass, so the two agree and hydration stays clean.
|
||||
hydratedAt := NewSignal(0.0)
|
||||
wasmruntime.AfterRender(func() {
|
||||
if hydratedAt.Get() == 0 {
|
||||
hydratedAt.Set(wasmruntime.Now())
|
||||
}
|
||||
})
|
||||
|
||||
// demoTree is called TWICE per render below — once for the DOM, once for the HTML.
|
||||
// That is the point: the two panes cannot drift, because there is only one of them.
|
||||
demoTree := func() *VNode {
|
||||
return Div(Attr("class", "flex items-center gap-3"),
|
||||
ui.Button(ui.ButtonProps{
|
||||
Color: ui.ButtonPrimary, Text: "Click me",
|
||||
OnClick: func() { clicks.Set(clicks.Get() + 1) },
|
||||
}),
|
||||
Span(Attr("class", "text-ink-soft"), Text("clicked "+itoa(clicks.Get())+" times")),
|
||||
)
|
||||
}
|
||||
|
||||
return func() *VNode {
|
||||
markup := RenderHTML(demoTree())
|
||||
|
||||
return Div(Attr("class", "mx-auto max-w-3xl"),
|
||||
H1(Attr("class", "text-3xl font-semibold tracking-tight text-text-heading"),
|
||||
Text("kjol")),
|
||||
P(Attr("class", "mt-3 leading-relaxed text-ink-soft"),
|
||||
Text("A shared base layer, factored out of several applications so they stay in sync. "+
|
||||
"Kjol is Norwegian for KEEL: the spine of a hull, the thing every other part is built onto.")),
|
||||
P(Attr("class", "mt-3 leading-relaxed text-ink-soft"),
|
||||
Text("It is not one library. It is a stack of them, in several languages, and each one is "+
|
||||
"documented here.")),
|
||||
|
||||
// ---- the layers ----
|
||||
//
|
||||
// The layers are the site. Everything else on this page is evidence that they
|
||||
// work; this is the part you are meant to click.
|
||||
H2(Attr("class", "mt-12 text-lg font-semibold text-text-heading"), Text("The layers")),
|
||||
layersGrid(d),
|
||||
|
||||
// ---- the demonstration ----
|
||||
//
|
||||
// This survives from the old landing page because it is the one thing on the site
|
||||
// that cannot be faked: the same Go function, rendered twice at once, as live DOM
|
||||
// and as the HTML string the server sent.
|
||||
H2(Attr("class", "mt-12 text-lg font-semibold text-text-heading"), Text("One function, two runtimes")),
|
||||
P(Attr("class", "mt-2 leading-relaxed text-ink-soft"),
|
||||
Text("Below is a single Go function, shown twice. On the left it has been reconciled into "+
|
||||
"the DOM and you can use it. On the right is the HTML the same function produces when "+
|
||||
"the server renders it — the markup that reached your browser before any WebAssembly "+
|
||||
"had loaded. Click the button; both move.")),
|
||||
|
||||
Div(Attr("class", "mt-5 border border-line sm:grid sm:grid-cols-2"),
|
||||
Div(Attr("class", "border-b border-line sm:border-b-0 sm:border-r"),
|
||||
paneLabel("in your browser"),
|
||||
Div(Attr("class", "px-4 py-8"), demoTree()),
|
||||
),
|
||||
Div(
|
||||
paneLabel(itoa(len(markup))+" bytes of HTML"),
|
||||
Pre(Attr("class", "whitespace-pre-wrap px-4 py-4 font-mono text-[12px] leading-relaxed text-ink-muted"),
|
||||
El("code", Text(prettyHTML(markup))),
|
||||
),
|
||||
),
|
||||
),
|
||||
P(Attr("class", "mt-3 text-sm leading-relaxed text-ink-muted"),
|
||||
Text("The right pane is not a picture of the source. It is vdom.RenderHTML, called on the "+
|
||||
"very tree the left pane is showing, recomputed on every click.")),
|
||||
|
||||
// ---- what is in it ----
|
||||
H2(Attr("class", "mt-12 text-lg font-semibold text-text-heading"), Text("What is in it")),
|
||||
Ul(Attr("class", "mt-3 space-y-1.5 leading-relaxed text-ink-soft"),
|
||||
item("Server-side rendering and client hydration, from one codebase."),
|
||||
item("Server components: mark a function and its code and state stay on the server."),
|
||||
item("A component kit — forms, tabs, modals, tooltips, toasts — with dark mode."),
|
||||
item("A table with filtering, sorting, column management, formulas, and CSV and PDF export."),
|
||||
item("Tailwind, compiled by a Go program that reads your Go."),
|
||||
),
|
||||
|
||||
// ---- building ----
|
||||
H2(Attr("class", "mt-12 text-lg font-semibold text-text-heading"), Text("Building it")),
|
||||
P(Attr("class", "mt-2 leading-relaxed text-ink-soft"),
|
||||
Text("Two commands. The first produced the page you are reading; the second serves it and "+
|
||||
"rebuilds on save.")),
|
||||
codeLang("terminal", "sh", buildTranscript),
|
||||
|
||||
// ---- close ----
|
||||
P(Attr("class", "mt-12 border-t border-line pt-6 leading-relaxed text-ink-soft"),
|
||||
Text("Every page of the documentation runs the code it documents — there are no screenshots "+
|
||||
"of components anywhere on this site. "),
|
||||
A(Attr("class", "text-accent underline underline-offset-4"),
|
||||
Attr("href", "/wasm"), navigate(d, "/wasm"), Text("Read the docs")),
|
||||
Text(", or "),
|
||||
A(Attr("class", "text-accent underline underline-offset-4"),
|
||||
Attr("href", "/wasm/kit"), navigate(d, "/wasm/kit"), Text("look at the components")),
|
||||
Text("."),
|
||||
),
|
||||
P(Attr("class", "mt-4 text-sm text-ink-muted"),
|
||||
Text(hydrationNote(hydratedAt.Get()))),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// item is one bullet.
|
||||
func item(text string) *VNode {
|
||||
return Li(Attr("class", "flex gap-2.5"),
|
||||
Span(Attr("class", "select-none text-ink-faint"), Text("—")),
|
||||
Span(Text(text)),
|
||||
)
|
||||
}
|
||||
|
||||
// paneLabel captions one half of the two-runtime demo.
|
||||
func paneLabel(title string) *VNode {
|
||||
return Div(Attr("class", "border-b border-line px-4 py-2"),
|
||||
Span(Attr("class", "font-mono text-[11px] uppercase tracking-widest text-ink-faint"), Text(title)),
|
||||
)
|
||||
}
|
||||
|
||||
// hydrationNote is the page's one measurement, written as a sentence rather than
|
||||
// displayed on a dashboard. It is a fact about this page, not a boast about the library,
|
||||
// and it reads better as the former.
|
||||
func hydrationNote(ms float64) string {
|
||||
if ms == 0 {
|
||||
return "This page was rendered by Go on the server. WebAssembly is still loading."
|
||||
}
|
||||
return "This page was rendered by Go on the server; WebAssembly took over " +
|
||||
strconv.FormatFloat(ms, 'f', 0, 64) + " ms later."
|
||||
}
|
||||
|
||||
// prettyHTML puts each element of a rendered tree on its own line. The markup shown is
|
||||
// otherwise byte-for-byte what RenderHTML produced — long class lists and all, because
|
||||
// tidying them for the demo would make the pane a lie.
|
||||
func prettyHTML(s string) string {
|
||||
return strings.ReplaceAll(s, "><", ">\n<")
|
||||
}
|
||||
|
||||
const buildTranscript = `$ go run ./build
|
||||
==> generating directive glue (//gowasm:page, //gowasm:layout, //gowasm:server)
|
||||
==> compiling Tailwind CSS -> wwwroot/app.css
|
||||
==> compiling ./wasm -> wwwroot/app.wasm (GOOS=js GOARCH=wasm)
|
||||
|
||||
$ go run ./server
|
||||
serving "./wwwroot" on http://localhost:8085`
|
||||
|
||||
// ---- about --------------------------------------------------------------
|
||||
|
||||
//gowasm:page /about static layout=public
|
||||
func AboutPage(d Deps) func() *VNode {
|
||||
return func() *VNode {
|
||||
return Div(Attr("class", "mx-auto max-w-3xl py-4"),
|
||||
P(Attr("class", "text-xs font-semibold uppercase tracking-widest text-accent"), Text("About")),
|
||||
H1(Attr("class", "mt-2 text-4xl font-semibold tracking-tight text-text-heading"), Text("Why this exists")),
|
||||
|
||||
P(Attr("class", "mt-6 text-lg leading-relaxed text-ink-soft"),
|
||||
Text("Kjol Web is one part of kjol — a shared base layer factored out of several applications "+
|
||||
"so they stay in sync. (Kjol is Norwegian for KEEL: the spine of a hull, the thing every "+
|
||||
"other part is built onto.) The applications had drifted: the same table, the same forms, "+
|
||||
"the same charts, each subtly different in each app, each fixed twice.")),
|
||||
|
||||
P(Attr("class", "mt-4 leading-relaxed text-ink-soft"),
|
||||
Text("The UI kit began as Solid.js components. Kjol Web is the same kit, written in Go and "+
|
||||
"compiled to WebAssembly — the same components, the same Tailwind, no JavaScript build. "+
|
||||
"That means one language across the server and the browser, and a table you can share "+
|
||||
"between a web app and a native one because it is a Go function, not a JSX file.")),
|
||||
|
||||
H2(Attr("class", "mt-12 text-2xl font-semibold tracking-tight text-text-heading"), Text("The rules it keeps")),
|
||||
Div(Attr("class", "mt-6 space-y-4"),
|
||||
principle("The framework never imports application code",
|
||||
"Where kjol needs something app-specific, the app injects it — an interface, a registration "+
|
||||
"call, a config struct. The dependency only ever points one way."),
|
||||
principle("Standard library only",
|
||||
"vdom, the reconciler, the component kit, the Tailwind compiler, the PDF writer: no "+
|
||||
"third-party Go packages. A dependency in the engine is a dependency in every app that "+
|
||||
"consumes it."),
|
||||
principle("The same code on both sides",
|
||||
"A component that cannot render on the server is a component that cannot be server-rendered. "+
|
||||
"The browser APIs components need are dual-build: real under WebAssembly, no-ops "+
|
||||
"natively — so one component measures the DOM and still SSRs."),
|
||||
),
|
||||
|
||||
Div(Attr("class", "mt-12 rounded-default border border-primary-border bg-primary-subtle p-5"),
|
||||
P(Attr("class", "font-semibold text-text-heading"), Text("This page is the proof, not a claim about it")),
|
||||
P(Attr("class", "mt-1 leading-relaxed text-ink-soft"),
|
||||
Text("Its HTML was rendered by Go on the server, and the same Go is running in your browser "+
|
||||
"now. View the source: the markup arrived complete.")),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func principle(title, body string) *VNode {
|
||||
return Div(Attr("class", "border-l-2 border-line pl-4"),
|
||||
H3(Attr("class", "font-semibold text-text-heading"), Text(title)),
|
||||
P(Attr("class", "mt-1 leading-relaxed text-ink-soft"), Text(body)),
|
||||
)
|
||||
}
|
||||
|
||||
// ---- server components --------------------------------------------------
|
||||
|
||||
//gowasm:page /wasm/server layout=app
|
||||
func ServerPage(d Deps) func() *VNode {
|
||||
// ServerCounter is a server component — calling it is just like calling any
|
||||
// component. On the client this resolves to a generated stub that mounts it
|
||||
// over /rsc; on the server it's the real function.
|
||||
counter := ServerCounter()
|
||||
|
||||
return func() *VNode {
|
||||
return docPage("Rendering", "Server components",
|
||||
"A server component's code and state never reach the browser. Mark a function with "+
|
||||
"//gowasm:server and the codegen replaces it, on the client, with a stub that renders it "+
|
||||
"over an HTTP round-trip — so calling one looks exactly like calling any other component.",
|
||||
|
||||
docSection("declaring", "Declaring one",
|
||||
prose("The directive is the whole API. The function stays an ordinary component: it takes "+
|
||||
"whatever it needs, and returns a VNode tree."),
|
||||
code("app/server_counter.go", serverSnippet),
|
||||
note("Why the state stays put",
|
||||
"The counter's value lives in a map on the server, keyed by instance. Nothing about it is "+
|
||||
"shipped to the client — the browser holds an id and a rendered fragment, and every "+
|
||||
"click asks the server what the next fragment should be."),
|
||||
),
|
||||
|
||||
docSection("try-it", "Try it",
|
||||
prose("Each click below is a POST to /rsc. The server runs the component again and returns the "+
|
||||
"new markup, which is merged into the DOM in place — the page is not reloaded and nothing "+
|
||||
"else on it is re-rendered."),
|
||||
demo("A counter whose state lives on the server", counter()),
|
||||
),
|
||||
|
||||
docSection("when", "When to reach for one",
|
||||
prose("When the component needs something the browser must not have: a database handle, a "+
|
||||
"secret, a large dataset you do not want to ship. The cost is a round-trip per interaction, "+
|
||||
"so it is the wrong tool for anything that has to feel instant."),
|
||||
apiTable(
|
||||
apiRow{"//gowasm:server", "Marks a component as server-side. The codegen writes a client stub in its place."},
|
||||
apiRow{"POST /rsc", "The endpoint the stub calls. Registered by the dev server; wire it into your own server with rsc.Handler."},
|
||||
apiRow{"rsc.Handler", "The http.HandlerFunc that runs the component and returns its rendered fragment."},
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const serverSnippet = `//gowasm:server
|
||||
func ServerCounter() func() *VNode {
|
||||
id := newInstanceID() // this state never leaves the server
|
||||
|
||||
return func() *VNode {
|
||||
return Div(
|
||||
Span(Text("count: "+itoa(counts[id]))),
|
||||
Button(
|
||||
On(EVENT_CLICK, func() { counts[id]++ }), // runs SERVER-side
|
||||
Text("+1"),
|
||||
),
|
||||
)
|
||||
}
|
||||
}`
|
||||
53
go/cmd/kjol-web/app/routes.gen.go
Normal file
53
go/cmd/kjol-web/app/routes.gen.go
Normal file
@@ -0,0 +1,53 @@
|
||||
// Code generated by wasmgen. DO NOT EDIT.
|
||||
package app
|
||||
|
||||
import "kjol/vdom"
|
||||
|
||||
// Routes maps each //gowasm:page path to its instantiated render function.
|
||||
func Routes(d Deps) map[string]func() *vdom.VNode {
|
||||
return map[string]func() *vdom.VNode{
|
||||
"/": HomePage(d),
|
||||
"/about": AboutPage(d),
|
||||
"/wasm": DocsPage(d),
|
||||
"/wasm/chart": ChartPage(d),
|
||||
"/wasm/data": DataPage(d),
|
||||
"/wasm/kit": KitPage(d),
|
||||
"/wasm/overlays": OverlaysPage(d),
|
||||
"/wasm/server": ServerPage(d),
|
||||
"/wasm/table": TablePage(d),
|
||||
}
|
||||
}
|
||||
|
||||
// StaticPaths are the routes the server pre-renders (SSR); others render client-side.
|
||||
var StaticPaths = map[string]bool{
|
||||
"/": true,
|
||||
"/about": true,
|
||||
"/wasm": true,
|
||||
"/wasm/chart": true,
|
||||
"/wasm/data": true,
|
||||
"/wasm/table": true,
|
||||
}
|
||||
|
||||
// RouteLayout maps each route to the name of the layout that wraps it.
|
||||
var RouteLayout = map[string]string{
|
||||
"/": "public",
|
||||
"/about": "public",
|
||||
"/wasm": "app",
|
||||
"/wasm/chart": "app",
|
||||
"/wasm/data": "app",
|
||||
"/wasm/kit": "app",
|
||||
"/wasm/overlays": "app",
|
||||
"/wasm/server": "app",
|
||||
"/wasm/table": "app",
|
||||
}
|
||||
|
||||
// LayoutFor wraps a page's content in the layout declared for its route.
|
||||
func LayoutFor(d Deps, path string, content *vdom.VNode) *vdom.VNode {
|
||||
switch RouteLayout[path] {
|
||||
case "app":
|
||||
return AppLayout(d, content)
|
||||
case "public":
|
||||
return PublicLayout(d, content)
|
||||
}
|
||||
return AppLayout(d, content)
|
||||
}
|
||||
11
go/cmd/kjol-web/app/server.gen.go
Normal file
11
go/cmd/kjol-web/app/server.gen.go
Normal file
@@ -0,0 +1,11 @@
|
||||
// Code generated by wasmgen. DO NOT EDIT.
|
||||
|
||||
//go:build !(js && wasm)
|
||||
|
||||
package app
|
||||
|
||||
import "kjol/rsc"
|
||||
|
||||
func init() {
|
||||
rsc.Register("ServerCounter", ServerCounter)
|
||||
}
|
||||
120
go/cmd/kjol-web/app/server_counter.go
Normal file
120
go/cmd/kjol-web/app/server_counter.go
Normal file
@@ -0,0 +1,120 @@
|
||||
//go:build !(js && wasm)
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
chart "github.com/wcharczuk/go-chart/v2"
|
||||
|
||||
. "kjol/vdom"
|
||||
ui "kjol/webui"
|
||||
)
|
||||
|
||||
// ServerCounter is a SERVER component — note it's written exactly like a client
|
||||
// component (same builders, signals, On handlers). The //gowasm:server directive
|
||||
// makes the build generate a client stub so calling ServerCounter() on the
|
||||
// frontend is identical to calling any component; the state and this render run
|
||||
// on the server (its chart is computed there with go-chart), and clicks
|
||||
// round-trip over /rsc.
|
||||
//
|
||||
// The chart plots the counter value against the wall-clock time of each click
|
||||
// (milliseconds since the first click), so spacing clicks out spreads the data
|
||||
// points along the x-axis. Because the component is stateless on the server, the
|
||||
// click points live in a signal that round-trips with the rest of its state
|
||||
// (a plain slice would reset on every request).
|
||||
//
|
||||
//gowasm:server
|
||||
func ServerCounter() func() *VNode {
|
||||
count := NewSignal(0)
|
||||
points := NewSignal([]clickPoint{})
|
||||
bump := func(delta int) {
|
||||
count.Set(count.Get() + delta)
|
||||
points.Set(append(points.Get(), clickPoint{T: time.Now().UnixMilli(), V: count.Get()}))
|
||||
}
|
||||
// No card of its own: the component draws bare content and lets the caller frame it.
|
||||
// The docs page already puts it in a demo panel, and a card inside a card gives you
|
||||
// two borders and two shadows around the same thing.
|
||||
return func() *VNode {
|
||||
return Div(
|
||||
Div(Attr("class", "flex items-center gap-2 mb-3"),
|
||||
Span(Attr("class", "text-ink-soft"), Text("Server counter: ")),
|
||||
Strong(Attr("class", "badge inline-flex items-center rounded-full bg-green-700 px-2.5 py-0.5 text-sm font-semibold text-white"), Text(strconv.Itoa(count.Get()))),
|
||||
Div(Attr("class", "ml-auto flex gap-1"),
|
||||
ui.Button(ui.ButtonProps{Color: ui.ButtonSecondary, Small: true, Text: "−", OnClick: func() { bump(-1) }}),
|
||||
ui.Button(ui.ButtonProps{Color: ui.ButtonGreen, Small: true, Text: "+", OnClick: func() { bump(1) }}),
|
||||
),
|
||||
),
|
||||
Div(Attr("class", "rounded-default border border-line bg-surface p-2 overflow-auto"),
|
||||
Raw(clickChartSVG(points.Get()))),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// clickPoint records one click: its wall-clock time and the resulting counter
|
||||
// value. Exported fields so the signal's JSON snapshot round-trips it.
|
||||
type clickPoint struct {
|
||||
T int64 // click time, Unix milliseconds
|
||||
V int // counter value after the click
|
||||
}
|
||||
|
||||
// clickChartSVG plots counter value vs. time-of-click (ms since the first
|
||||
// click) as a line graph. Explicit axis ranges keep it valid for the tricky
|
||||
// cases (a single click, or several clicks within the same millisecond).
|
||||
func clickChartSVG(points []clickPoint) string {
|
||||
if len(points) == 0 {
|
||||
return `<span class="text-muted">Click + / − to plot the counter over time (ms since the first click).</span>`
|
||||
}
|
||||
t0 := points[0].T
|
||||
xs := make([]float64, len(points))
|
||||
ys := make([]float64, len(points))
|
||||
minY, maxY := 0.0, 0.0 // keep the zero baseline in view for context
|
||||
for i, p := range points {
|
||||
xs[i] = float64(p.T - t0)
|
||||
ys[i] = float64(p.V)
|
||||
if ys[i] < minY {
|
||||
minY = ys[i]
|
||||
}
|
||||
if ys[i] > maxY {
|
||||
maxY = ys[i]
|
||||
}
|
||||
}
|
||||
maxX := xs[len(xs)-1]
|
||||
if maxX <= 0 {
|
||||
maxX = 1 // rapid or single clicks: avoid a zero-width x-range
|
||||
}
|
||||
if minY == maxY {
|
||||
maxY++ // avoid a zero-height y-range
|
||||
}
|
||||
graph := chart.Chart{
|
||||
Title: "Counter over time (computed on the server)",
|
||||
TitleStyle: chart.Style{FontSize: 14},
|
||||
Background: chart.Style{Padding: chart.Box{Top: 48, Left: 20, Right: 20, Bottom: 40}},
|
||||
Height: 260,
|
||||
XAxis: chart.XAxis{
|
||||
Name: "ms since first click",
|
||||
Range: &chart.ContinuousRange{Min: 0, Max: maxX},
|
||||
},
|
||||
YAxis: chart.YAxis{
|
||||
Name: "counter",
|
||||
Range: &chart.ContinuousRange{Min: minY, Max: maxY},
|
||||
},
|
||||
Series: []chart.Series{
|
||||
chart.ContinuousSeries{
|
||||
XValues: xs,
|
||||
YValues: ys,
|
||||
Style: chart.Style{
|
||||
StrokeColor: chart.ColorGreen, StrokeWidth: 2,
|
||||
DotColor: chart.ColorGreen, DotWidth: 4, // a dot at each click
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if graph.Render(chart.SVG, &buf) != nil {
|
||||
return `<span class="text-danger">chart error</span>`
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
171
go/cmd/kjol-web/app/ssr_test.go
Normal file
171
go/cmd/kjol-web/app/ssr_test.go
Normal file
@@ -0,0 +1,171 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"kjol/vdom"
|
||||
"kjol/webui"
|
||||
)
|
||||
|
||||
func TestSSRPages(t *testing.T) {
|
||||
for _, path := range []string{"/", "/about", "/wasm/chart", "/wasm/data", "/wasm/table", "/wasm/overlays", "/wasm/kit"} {
|
||||
deps := Deps{Path: func() string { return path }}
|
||||
html := vdom.RenderHTML(Shell(deps, Routes(deps)))
|
||||
t.Logf("%-8s %6d bytes portals=%d", path, len(html), strings.Count(html, "data-portal"))
|
||||
if len(html) < 200 {
|
||||
t.Errorf("%s rendered only %d bytes", path, len(html))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSRTablePage(t *testing.T) {
|
||||
deps := Deps{Path: func() string { return "/wasm/table" }}
|
||||
html := vdom.RenderHTML(Shell(deps, Routes(deps)))
|
||||
|
||||
// The table persists a personal layout in localStorage, which the SERVER CANNOT
|
||||
// READ. So the server renders a SKELETON, not the default table: if it rendered
|
||||
// the default one, a user who had reordered their columns would watch them
|
||||
// rearrange themselves once the wasm booted.
|
||||
//
|
||||
// This is a real cost — the page ships no table content — and it is the price of
|
||||
// never showing the wrong table. See webui.RestoreLayout.
|
||||
if !strings.Contains(html, `aria-busy="true"`) {
|
||||
t.Error("SSR /table should render the loading skeleton, not a table")
|
||||
}
|
||||
if !strings.Contains(html, "animate-pulse") {
|
||||
t.Error("the skeleton bars are missing")
|
||||
}
|
||||
if strings.Contains(html, "Ada Lovelace") {
|
||||
t.Error("SSR rendered table CONTENT — a user with a saved layout would watch it rearrange")
|
||||
}
|
||||
}
|
||||
|
||||
// renderedTable drives the very table the page renders, past its skeleton. Natively
|
||||
// there is nothing to restore, so RestoreLayout just marks the layout settled.
|
||||
func renderedTable(t *testing.T) string {
|
||||
t.Helper()
|
||||
highlight := vdom.NewSignal("")
|
||||
table := newEmployeeTable(highlight)
|
||||
table.SetRows(employees())
|
||||
table.RestoreLayout()
|
||||
return vdom.RenderHTML(table.Render())
|
||||
}
|
||||
|
||||
// Once the layout has settled, the table renders in full.
|
||||
func TestTableRendersOnceSettled(t *testing.T) {
|
||||
html := renderedTable(t)
|
||||
|
||||
for _, want := range []string{"Ada Lovelace", "Salary"} {
|
||||
if !strings.Contains(html, want) {
|
||||
t.Errorf("settled table missing %q", want)
|
||||
}
|
||||
}
|
||||
// PerPage is 5, so page one holds 5 of the 12 rows.
|
||||
if got := strings.Count(html, "@example.com"); got != 5 {
|
||||
t.Errorf("rendered %d rows, want 5 (one page)", got)
|
||||
}
|
||||
// The Rank column is HiddenByDefault.
|
||||
if strings.Contains(html, ">Rank<") {
|
||||
t.Error("a HiddenByDefault column was rendered")
|
||||
}
|
||||
if !strings.Contains(html, "Page 1 of 3") {
|
||||
t.Error("pagination did not compute 3 pages for 12 rows at 5/page")
|
||||
}
|
||||
}
|
||||
|
||||
// Calculated columns, end to end through the page, in all three shapes.
|
||||
//
|
||||
// Page 1 (declared order):
|
||||
//
|
||||
// salary 1200.50 1500.00 980.00 1340.00 1610.25
|
||||
// bonus 150.00 300.00 0.00 220.00 400.00
|
||||
func TestSSRCalculatedColumns(t *testing.T) {
|
||||
html := renderedTable(t)
|
||||
|
||||
// BASIC: sum over the operand columns [Salary, Bonus], combined ACROSS each row.
|
||||
// If this ever aggregated DOWN the column instead, every row would read the same
|
||||
// number — which is exactly the bug these values are here to catch.
|
||||
for _, want := range []string{"$1,350.50", "$1,800.00", "$980.00", "$1,560.00", "$2,010.25"} {
|
||||
if !strings.Contains(html, want) {
|
||||
t.Errorf("Total comp missing %s (a per-row Salary + Bonus)", want)
|
||||
}
|
||||
}
|
||||
|
||||
// ADVANCED: ([Salary] + [Bonus]) * 12.
|
||||
for _, want := range []string{"$16,206.00", "$21,600.00", "$11,760.00"} {
|
||||
if !strings.Contains(html, want) {
|
||||
t.Errorf("Annual column missing %s", want)
|
||||
}
|
||||
}
|
||||
|
||||
// ADVANCED, position-dependent: SUM({Salary:1:ROW()}) accumulates down the rows.
|
||||
for _, want := range []string{"$2,700.50", "$3,680.50", "$5,020.50", "$6,630.75"} {
|
||||
if !strings.Contains(html, want) {
|
||||
t.Errorf("running total missing %s", want)
|
||||
}
|
||||
}
|
||||
|
||||
// SUMMARY: aggregated DOWN the column, over ALL 12 filtered rows — not the 5 on
|
||||
// this page. 1200.50+1500+980+1340+1610.25+1120+1275.75+1050+1400+860+1180+990.
|
||||
if !strings.Contains(html, "$14,506.50") {
|
||||
t.Error("footer did not total the whole filtered set ($14,506.50)")
|
||||
}
|
||||
if !strings.Contains(html, "Average salary") {
|
||||
t.Error("summary row label missing")
|
||||
}
|
||||
}
|
||||
|
||||
// The export path, driven through the very table the /table page renders.
|
||||
//
|
||||
// Export must write what the FILTER selected — every matching row across every page
|
||||
// — not the five rows on screen; the columns the user can SEE, in their order; and
|
||||
// the calculated columns, with each row's own value.
|
||||
func TestTableExport(t *testing.T) {
|
||||
highlight := vdom.NewSignal("")
|
||||
table := newEmployeeTable(highlight)
|
||||
table.RestoreLayout() // nothing to restore natively; reveals the table over its skeleton
|
||||
table.SetRows(employees())
|
||||
|
||||
// Filter to one team, then render (which resolves FilteredRows).
|
||||
table.SetSearchValue("Team", "Research", true)
|
||||
table.Render()
|
||||
|
||||
csv := string(webui.ExportCSV(table.ExportColumns(), table.FilteredRows(), nil))
|
||||
|
||||
// PerPage is 5 and Research has 4 members, but the point is that export ignores
|
||||
// paging entirely: every filtered row, no one else's.
|
||||
for _, want := range []string{"Alan Turing", "Katherine Johnson", "Barbara Liskov", "Evelyn Boyd Granville"} {
|
||||
if !strings.Contains(csv, want) {
|
||||
t.Errorf("CSV missing filtered row %q", want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(csv, "Ada Lovelace") {
|
||||
t.Error("CSV contains a row the filter excluded")
|
||||
}
|
||||
// Rank is HiddenByDefault, so it must not be exported.
|
||||
if strings.Contains(csv, "Item 10") {
|
||||
t.Error("CSV exported a hidden column")
|
||||
}
|
||||
// The calculated columns come along, and the running total ACCUMULATES —
|
||||
// $1,500.00 then $2,840.00 (Turing + Johnson), not the same number twice.
|
||||
if !strings.Contains(csv, "Running total") || !strings.Contains(csv, "$2,840.00") {
|
||||
t.Errorf("running total did not accumulate in the export:\n%s", csv)
|
||||
}
|
||||
|
||||
// And the PDF: a real file, with the same filtered content.
|
||||
pdf := table.ExportPDFBytes(webui.AutoTablePDFHeader{
|
||||
Title: "Employees", ShowDate: true, Orientation: webui.PDF_ORIENTATION_LANDSCAPE,
|
||||
})
|
||||
if !bytes.HasPrefix(pdf, []byte("%PDF-")) || !bytes.Contains(pdf, []byte("%%EOF")) {
|
||||
t.Fatalf("PDF is not a PDF (%d bytes)", len(pdf))
|
||||
}
|
||||
if out := os.Getenv("PDF_OUT"); out != "" {
|
||||
if err := os.WriteFile(out, pdf, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Logf("wrote %s (%d bytes)", out, len(pdf))
|
||||
}
|
||||
}
|
||||
355
go/cmd/kjol-web/app/table.go
Normal file
355
go/cmd/kjol-web/app/table.go
Normal file
@@ -0,0 +1,355 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
. "kjol/vdom"
|
||||
ui "kjol/webui"
|
||||
)
|
||||
|
||||
// Employee is a row in the table demo. Salary and Bonus are both money, so a
|
||||
// calculated column has two numeric columns to combine ACROSS a row.
|
||||
type Employee struct {
|
||||
Name string
|
||||
Email string
|
||||
Team string
|
||||
Status string
|
||||
Salary string
|
||||
Bonus string
|
||||
Rank string
|
||||
Note string
|
||||
}
|
||||
|
||||
func employees() []any {
|
||||
rows := []Employee{
|
||||
{"Ada Lovelace", "ada@example.com", "Engineering", "active", "$1,200.50", "$150.00", "Item 2", "Wrote the first algorithm."},
|
||||
{"Alan Turing", "alan@example.com", "Research", "active", "$1,500.00", "$300.00", "Item 10", "Decidability, and the machine."},
|
||||
{"Grace Hopper", "grace@example.com", "Engineering", "inactive", "$980.00", "$0.00", "Item 1", "Found the first bug. Literally."},
|
||||
{"Katherine Johnson", "katherine@example.com", "Research", "active", "$1,340.00", "$220.00", "Item 3", "Orbital mechanics, by hand."},
|
||||
{"Margaret Hamilton", "margaret@example.com", "Engineering", "active", "$1,610.25", "$400.00", "Item 21", "Coined 'software engineering'."},
|
||||
{"Barbara Liskov", "barbara@example.com", "Research", "inactive", "$1,120.00", "$90.00", "Item 7", "The substitution principle."},
|
||||
{"Radia Perlman", "radia@example.com", "Networking", "active", "$1,275.75", "$180.00", "Item 12", "Spanning tree protocol."},
|
||||
{"Karen Sparck Jones", "karen@example.com", "Research", "active", "$1,050.00", "$60.00", "Item 5", "Inverse document frequency."},
|
||||
{"Frances Allen", "frances@example.com", "Engineering", "inactive", "$1,400.00", "$250.00", "Item 9", "Optimizing compilers."},
|
||||
{"Jean Bartik", "jean@example.com", "Engineering", "active", "$860.00", "$40.00", "Item 4", "Programmed the ENIAC."},
|
||||
{"Evelyn Boyd Granville", "evelyn@example.com", "Research", "active", "$1,180.00", "$130.00", "Item 15", "Trajectory analysis."},
|
||||
{"Annie Easley", "annie@example.com", "Networking", "inactive", "$990.00", "$75.00", "Item 6", "Rocket propulsion code."},
|
||||
}
|
||||
out := make([]any, len(rows))
|
||||
for i, r := range rows {
|
||||
out[i] = r
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func emp(row any) Employee { return row.(Employee) }
|
||||
|
||||
func tableColumns() []ui.AutoTableColumn {
|
||||
return []ui.AutoTableColumn{
|
||||
{
|
||||
Key: "name", DisplayName: "Name", Sortable: true, SortIdentifier: "Name",
|
||||
CSV: true, CSVValue: func(r any) string { return emp(r).Name },
|
||||
// No Toggleable: the name is what identifies a row, so it cannot be hidden.
|
||||
Cell: func(r any) *VNode { return ui.AutoTableTdLeft("text-ink", Text(emp(r).Name)) },
|
||||
},
|
||||
{
|
||||
Key: "email", DisplayName: "Email", Sortable: true, SortIdentifier: "Email",
|
||||
Toggleable: true, CSV: true, CSVValue: func(r any) string { return emp(r).Email },
|
||||
Cell: func(r any) *VNode { return ui.AutoTableTdLeft("text-ink-muted", Text(emp(r).Email)) },
|
||||
},
|
||||
{
|
||||
Key: "team", DisplayName: "Team", Sortable: true, SortIdentifier: "Team",
|
||||
Toggleable: true, CSV: true, CSVValue: func(r any) string { return emp(r).Team },
|
||||
Cell: func(r any) *VNode { return ui.AutoTableTdLeft("", Text(emp(r).Team)) },
|
||||
},
|
||||
{
|
||||
Key: "status", DisplayName: "Status", Sortable: true, SortIdentifier: "Status",
|
||||
Toggleable: true, CSV: true, CSVValue: func(r any) string { return emp(r).Status },
|
||||
Cell: func(r any) *VNode {
|
||||
color := ui.BadgeGreen
|
||||
if emp(r).Status != "active" {
|
||||
color = ui.BadgeNeutral
|
||||
}
|
||||
return ui.AutoTableTdLeft("", ui.Badge(ui.BadgeProps{Color: color}, Text(emp(r).Status)))
|
||||
},
|
||||
},
|
||||
{
|
||||
// SortTypeMoney parses "$1,200.50" as a number — a plain string sort would
|
||||
// put $1,200.50 before $980.00.
|
||||
Key: "salary", DisplayName: "Salary", DisplayPosition: ui.COL_POS_RIGHT,
|
||||
Sortable: true, SortIdentifier: "Salary", SortType: ui.SortTypeMoney,
|
||||
Toggleable: true, CSV: true, CSVValue: func(r any) string { return emp(r).Salary },
|
||||
Cell: func(r any) *VNode { return ui.AutoTableTdRight("tabular-nums", Text(emp(r).Salary)) },
|
||||
},
|
||||
{
|
||||
Key: "bonus", DisplayName: "Bonus", DisplayPosition: ui.COL_POS_RIGHT,
|
||||
Sortable: true, SortIdentifier: "Bonus", SortType: ui.SortTypeMoney,
|
||||
Toggleable: true, CSV: true, CSVValue: func(r any) string { return emp(r).Bonus },
|
||||
Cell: func(r any) *VNode { return ui.AutoTableTdRight("tabular-nums", Text(emp(r).Bonus)) },
|
||||
},
|
||||
{
|
||||
// SortTypeNumeric sorts "Item 2" before "Item 10".
|
||||
Key: "rank", DisplayName: "Rank", Sortable: true, SortIdentifier: "Rank",
|
||||
SortType: ui.SortTypeNumeric, Toggleable: true, HiddenByDefault: true,
|
||||
CSV: true, CSVValue: func(r any) string { return emp(r).Rank },
|
||||
Cell: func(r any) *VNode { return ui.AutoTableTdLeft("", Text(emp(r).Rank)) },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// newEmployeeTable builds the table controller.
|
||||
//
|
||||
// It is factored out of TablePage so a test can drive the very same table the page
|
||||
// renders — the export test checks the bytes this exact configuration produces,
|
||||
// rather than a second copy of it that could drift.
|
||||
//
|
||||
// The controller owns the search, sort, page, expansion and column state. Build it
|
||||
// ONCE, never inside a render closure: rebuilding it per frame would reset every
|
||||
// filter on each keystroke.
|
||||
|
||||
func newEmployeeTable(highlight *Signal[string]) *ui.AutoTableState {
|
||||
return ui.NewAutoTableState(tableColumns(), ui.AutoTableStateOptions{
|
||||
PerPage: 5,
|
||||
|
||||
// The table PAGES ITSELF to wherever the highlighted row landed after
|
||||
// filtering and sorting.
|
||||
HighlightMatch: func(r any) bool {
|
||||
return highlight.Get() != "" && emp(r).Email == highlight.Get()
|
||||
},
|
||||
|
||||
// Calculated columns come in two shapes, and the difference is the thing to
|
||||
// understand:
|
||||
//
|
||||
// BASIC — a function over OPERAND COLUMNS, combined ACROSS each row.
|
||||
// Sum over [Salary, Bonus] is this row's salary + bonus. It does
|
||||
// NOT total the column. Operands are column KEYS (SortIdentifier),
|
||||
// and subtract/divide are binary and ORDERED.
|
||||
//
|
||||
// ADVANCED — an Excel-style formula, which names columns by DISPLAY name:
|
||||
// [Salary] is this row's cell, {Salary} is the whole column, and
|
||||
// {Salary:1:ROW()} is everything up to this row — a running total.
|
||||
//
|
||||
// Either way they are evaluated against the FILTERED, SORTED rows, so filtering
|
||||
// re-runs them. (ToCalcNumber parses "$1,200.50" for you.)
|
||||
Calculated: []ui.UserCalculatedColumn{
|
||||
{
|
||||
// Basic: two columns, added together, per row.
|
||||
ID: "comp", DisplayName: "Total comp", Fn: ui.CALC_FN_SUM,
|
||||
Operands: []string{"Salary", "Bonus"},
|
||||
DataType: ui.CALC_TYPE_MONEY, DisplayPosition: ui.COL_POS_RIGHT,
|
||||
},
|
||||
{
|
||||
// Advanced: a formula.
|
||||
ID: "annual", DisplayName: "Annual", Fn: ui.CALC_FN_CUSTOM,
|
||||
Formula: "([Salary] + [Bonus]) * 12", DataType: ui.CALC_TYPE_MONEY,
|
||||
DisplayPosition: ui.COL_POS_RIGHT,
|
||||
},
|
||||
{
|
||||
// Advanced, and position-dependent: a running total down the page.
|
||||
ID: "running", DisplayName: "Running total", Fn: ui.CALC_FN_CUSTOM,
|
||||
Formula: "SUM({Salary:1:ROW()})", DataType: ui.CALC_TYPE_MONEY,
|
||||
DisplayPosition: ui.COL_POS_RIGHT,
|
||||
},
|
||||
},
|
||||
// A summary row goes the OTHER way: one column, aggregated DOWN the whole
|
||||
// filtered set — not just the page on screen. Basic mode does that with a
|
||||
// function + one operand; this one uses a formula for the same thing.
|
||||
SummaryRows: []ui.UserSummaryRow{
|
||||
{ID: "total", Label: "Total salary", Fn: ui.CALC_FN_SUM,
|
||||
Operands: []string{"Salary"}, DataType: ui.CALC_TYPE_MONEY},
|
||||
{ID: "avg", Label: "Average salary", Fn: ui.CALC_FN_CUSTOM,
|
||||
Formula: "AVERAGE({Salary})", DataType: ui.CALC_TYPE_MONEY},
|
||||
},
|
||||
|
||||
Accordion: true,
|
||||
RowKey: func(r any) string { return emp(r).Email },
|
||||
AccordionContent: func(r any) *VNode {
|
||||
return P(Attr("class", "px-4 py-2 text-sm text-ink-soft"), Text(emp(r).Note))
|
||||
},
|
||||
|
||||
Columns: ui.AutoTableColumnOptions{
|
||||
Toggleable: true,
|
||||
Draggable: true,
|
||||
Resizable: true,
|
||||
StorageKey: "gowasm-example-employees",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
//gowasm:page /wasm/table layout=app static
|
||||
func TablePage(d Deps) func() *VNode {
|
||||
// Which row to spotlight, if any.
|
||||
highlight := NewSignal("")
|
||||
|
||||
table := newEmployeeTable(highlight)
|
||||
table.SetRows(employees())
|
||||
|
||||
// The export menu, with a submenu for the PDF's page orientation. Both are
|
||||
// controllers, both built once. A submenu is Standalone — opening it must not
|
||||
// close the menu it lives in.
|
||||
exportMenu := ui.NewMenu(ui.MenuOptions{Placement: ui.PlacementBottomEnd})
|
||||
pdfSub := ui.NewSubmenu(exportMenu)
|
||||
|
||||
// What the PDF prints above the table.
|
||||
//
|
||||
// Note what is NOT here: the footer lines. The export takes the table's OWN
|
||||
// summary rows — including any the user builds at runtime in the Calculated
|
||||
// editor — and evaluates them against the same filtered rows it is printing. Only
|
||||
// pass Summaries explicitly to print something that is not one of the table's own
|
||||
// rows.
|
||||
pdfHeader := func(landscape bool) ui.AutoTablePDFHeader {
|
||||
orientation := ui.PDF_ORIENTATION_PORTRAIT
|
||||
if landscape {
|
||||
orientation = ui.PDF_ORIENTATION_LANDSCAPE
|
||||
}
|
||||
return ui.AutoTablePDFHeader{
|
||||
Title: "Employees",
|
||||
Subtitle: "Exported from the Kjol Web example",
|
||||
ShowDate: true,
|
||||
Orientation: orientation,
|
||||
}
|
||||
}
|
||||
|
||||
return func() *VNode {
|
||||
return docPage("Components", "AutoTable",
|
||||
"A table that filters, sorts, pages, reorders, resizes, computes and exports — configured with "+
|
||||
"a column list and a slice of rows. Everything a user changes about it is theirs and persists; "+
|
||||
"everything it exports is what they filtered, not what happened to be on screen.",
|
||||
|
||||
docSection("defining", "Defining one",
|
||||
prose("A column says how to read a field, how to sort it, and how to render it. The state object "+
|
||||
"is a CONTROLLER: build it once, alongside your signals — never inside the render, which "+
|
||||
"would hand it fresh refs and a fresh idea of which page it was on every frame."),
|
||||
code("app/table.go", tableSnippet),
|
||||
note("The server renders a skeleton, on purpose",
|
||||
"The layout — column order, widths, what is hidden, the calculated columns — lives in the "+
|
||||
"browser's localStorage, which the server cannot read. So the server ships a skeleton "+
|
||||
"rather than the DEFAULT table: a user who had reordered their columns would otherwise "+
|
||||
"watch them rearrange themselves the moment the WebAssembly booted."),
|
||||
),
|
||||
|
||||
docSection("try-it", "Try it",
|
||||
prose("Search matches name or email. Sort by Salary and it parses the currency, so $980 sorts "+
|
||||
"below $1,200.50. Unhide Rank and sort that: \"Item 2\" comes before \"Item 10\", because "+
|
||||
"numbers inside text are compared as numbers. Drag a header to reorder it, drag its right "+
|
||||
"edge to resize — reload the page and both are still where you left them."),
|
||||
prose("Filter it, then export. You get every matching row across every page, in the column order "+
|
||||
"you dragged them into, with the calculated columns computed per row."),
|
||||
),
|
||||
|
||||
table.Render(
|
||||
ui.AutoTableWithHover(),
|
||||
ui.AutoTableWithAlternate(),
|
||||
ui.AutoTableWithSurroundingBorder(),
|
||||
ui.AutoTableWithPaginationShowAll(),
|
||||
ui.AutoTableWithSearchFields(
|
||||
// One box, several fields: a global search.
|
||||
table.GlobalSearch("Search name or email…", "Name", "Email"),
|
||||
// Exact-match dropdown.
|
||||
table.SelectSearch("Status", []string{"active", "inactive"}, "Any status"),
|
||||
// IN-set: matches any of the selected teams.
|
||||
table.MultiSelectSearch("Team", "Any team", []string{"Engineering", "Research", "Networking"}),
|
||||
),
|
||||
ui.AutoTableWithToolbarActions(
|
||||
table.ColumnPicker(),
|
||||
|
||||
// Build calculated columns and footer rows at runtime. Basic picks a
|
||||
// function and the columns it combines across each row; Advanced writes
|
||||
// a formula, with insert menus for columns, functions and constants.
|
||||
// The formula is compiled and previewed against the real first row as
|
||||
// you type, so a typo shows up immediately rather than as a column of
|
||||
// dashes. What you build is persisted with the rest of the layout.
|
||||
table.CalculatedColumnEditor(),
|
||||
|
||||
// Export writes what the FILTER selected — every matching row across
|
||||
// every page — not the five rows on screen. And it writes the columns
|
||||
// you can actually see, in the order you dragged them into.
|
||||
exportMenu.TriggerFunc(ui.MenuTriggerProps{Tag: "div"}, func(open bool) *VNode {
|
||||
return ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Small: true,
|
||||
Icon: "download", Text: "Export"})
|
||||
}),
|
||||
exportMenu.Content("",
|
||||
exportMenu.Item(ui.MenuItemProps{Icon: "file-csv",
|
||||
OnClick: func() { table.DownloadCSV("employees") }}, Text("Download CSV")),
|
||||
|
||||
// A submenu — portaled, so it is not clipped by the menu's own
|
||||
// overflow-y-auto, which is what broke it before.
|
||||
pdfSub.Submenu(ui.SubmenuProps{Trigger: "Download PDF", Icon: "file-pdf"},
|
||||
pdfSub.Item(ui.MenuItemProps{
|
||||
OnClick: func() { table.DownloadPDF("employees", pdfHeader(false)) }}, Text("Portrait")),
|
||||
pdfSub.Item(ui.MenuItemProps{
|
||||
OnClick: func() { table.DownloadPDF("employees", pdfHeader(true)) }}, Text("Landscape")),
|
||||
),
|
||||
|
||||
ui.MenuDivider(""),
|
||||
exportMenu.Item(ui.MenuItemProps{Icon: "print",
|
||||
OnClick: func() { table.PrintPDF(pdfHeader(true)) }}, Text("Print")),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Highlight + auto-page-jump: Radia is on page 3 by default, and the table
|
||||
// pages itself to wherever she actually is once filters and sorting move her.
|
||||
row("mt-4 flex flex-wrap items-center gap-2",
|
||||
ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Small: true,
|
||||
Text: "Find Radia Perlman",
|
||||
OnClick: func() { highlight.Set("radia@example.com") }}),
|
||||
ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Small: true,
|
||||
Text: "Clear highlight",
|
||||
OnClick: func() { highlight.Set("") }}),
|
||||
),
|
||||
|
||||
docSection("calculated", "Calculated columns",
|
||||
prose("The toolbar's calculator builds new columns at runtime, in two modes. Basic picks a "+
|
||||
"function and the columns it combines ACROSS each row — sum of Salary and Bonus, per "+
|
||||
"person. Advanced writes a formula, with insert menus for columns, functions and constants: "+
|
||||
"([Salary] + [Bonus]) * 12."),
|
||||
prose("A summary row is the other axis: it aggregates ONE column DOWN the filtered rows and "+
|
||||
"prints the result in the footer. Confusing the two is the classic bug here — a column that "+
|
||||
"aggregates down shows every row the same number, and it looks plausible enough to ship."),
|
||||
codeLang("formulas", "syntax", formulaSnippet),
|
||||
note("Compiled as you type",
|
||||
"The formula is parsed and evaluated against the real first row while you write it, so a "+
|
||||
"typo shows up as an error under the box — not as a column of dashes discovered later."),
|
||||
),
|
||||
|
||||
docSection("export", "Export",
|
||||
prose("CSV and PDF are written in Go, standard library only — the PDF writer builds its own "+
|
||||
"xref table and embeds Helvetica metrics. Export takes the FILTERED rows, the VISIBLE "+
|
||||
"columns, in the user's order, including whatever they calculated."),
|
||||
apiTable(
|
||||
apiRow{"NewAutoTableState", "Build the controller: the columns, and where to persist the layout."},
|
||||
apiRow{".SetRows", "Hand it the data. It filters, sorts and pages from there."},
|
||||
apiRow{".RestoreLayout", "Read the saved layout and reveal the table over its skeleton. Call it once, on the client."},
|
||||
apiRow{".FilteredRows / .ExportColumns", "What the user selected, and what they can see — the inputs to any export."},
|
||||
apiRow{"ExportCSV / ExportPDF", "Write the bytes. DownloadCSV / DownloadPDF / PrintPDF do it and hand them to the browser."},
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const tableSnippet = `// A controller: built ONCE, next to your signals — never inside the render.
|
||||
table := ui.NewAutoTableState([]ui.AutoTableColumn{
|
||||
{DisplayName: "Name", SortIdentifier: "Name", Sortable: true,
|
||||
Cell: func(r any) *VNode { return Text(r.(Employee).Name) }},
|
||||
{DisplayName: "Salary", SortIdentifier: "Salary", Sortable: true,
|
||||
SortType: ui.SortTypeNumeric, // parses the currency: $980 < $1,200.50
|
||||
Cell: func(r any) *VNode { return Text(money(r.(Employee).Salary)) }},
|
||||
{DisplayName: "Rank", HiddenByDefault: true},
|
||||
}, ui.AutoTableStateOptions{
|
||||
PerPage: 5,
|
||||
Columns: ui.AutoTableColumnOptions{
|
||||
StorageKey: "employees", // order, widths, visibility — the user's, and persisted
|
||||
},
|
||||
})
|
||||
|
||||
table.SetRows(employees())`
|
||||
|
||||
const formulaSnippet = `A COLUMN combines operands ACROSS one row:
|
||||
|
||||
sum[Salary, Bonus] -> 1200.50 + 150.00 = 1350.50 (per person)
|
||||
([Salary] + [Bonus]) * 12 -> the annualised figure
|
||||
SUM({Salary:1:ROW()}) -> a running total, down the rows
|
||||
|
||||
A SUMMARY ROW aggregates ONE column DOWN the filtered rows:
|
||||
|
||||
avg[Salary] -> one number, printed in the footer`
|
||||
Reference in New Issue
Block a user