Add landing page for kjol, documentation
This commit is contained in:
18
CLAUDE.md
18
CLAUDE.md
@@ -70,9 +70,21 @@ GOOS=js GOARCH=wasm go -C go test -exec="node testdata/domexec.js" ./wasmruntime
|
||||
```
|
||||
|
||||
`webui` components that measure the page (Tooltip, Popover, Menu, Modal, DatePicker, Tutorial,
|
||||
AutoTable) are **controllers**: create them once alongside your signals, never inside a render
|
||||
closure. Floating panels share one positioning engine (`webui/position.go`, pure math, unit-tested
|
||||
natively) driven by the `Floating` controller (`webui/floating.go`).
|
||||
AutoTable, SignaturePad, AsyncCombobox) are **controllers**: create them once alongside your
|
||||
signals, never inside a render closure. Floating panels share one positioning engine
|
||||
(`webui/position.go`, pure math, unit-tested natively) driven by the `Floating` controller
|
||||
(`webui/floating.go`).
|
||||
|
||||
**Theming / dark mode.** The kit is themed by **semantic tokens**, not by a `dark:` variant on
|
||||
every class: components say `bg-surface` / `border-line` / `text-ink` / `text-accent` and never
|
||||
name a colour, so a theme is ten CSS variables rather than four hundred class strings. The app
|
||||
must define them (see `webui.ThemeTokens` for the required set, and the example's `css/app.css`
|
||||
for a working pair) plus `@custom-variant dark (&:where(.dark, .dark *));` — the built-in `dark`
|
||||
variant is a `prefers-color-scheme` media query, which a site with its own switch cannot use.
|
||||
Only genuinely *coloured* things (an alert's red tint) carry `dark:` variants. `webui.Theme` is
|
||||
the controller (`Toggle`, `ThemeToggle`, `Init`); `webui.ThemeBootScript` goes in the document
|
||||
head **before** the stylesheet, or dark-mode users get a white flash until the wasm loads. It is
|
||||
the only JavaScript in a gowasm app.
|
||||
|
||||
### web/
|
||||
|
||||
|
||||
@@ -63,19 +63,74 @@ func pieSVG(values []int) string {
|
||||
//gowasm:page /chart static layout=app
|
||||
func ChartPage(d Deps) func() *VNode {
|
||||
data := NewSignal(fixedChartData())
|
||||
|
||||
return func() *VNode {
|
||||
values := data.Get()
|
||||
return Div(Attr("class", "space-y-6"),
|
||||
Div(
|
||||
H2(Attr("class", "text-2xl font-semibold tracking-tight text-text-heading"), Text("Charts — go-chart (SSR + hydrate)")),
|
||||
P(Attr("class", "mt-1 text-neutral-500"),
|
||||
Text("Rendered to SVG on the server, hydrated on the client; Shuffle re-renders client-side.")),
|
||||
|
||||
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."},
|
||||
),
|
||||
ui.Button(ui.ButtonProps{Color: ui.ButtonPrimary, Text: "Shuffle data", OnClick: func() { data.Set(randomValues()) }}),
|
||||
Div(Attr("class", "grid gap-4 lg:grid-cols-12"),
|
||||
Div(Attr("class", "lg:col-span-7 rounded-default border border-neutral-200 bg-white p-3 shadow-xs overflow-auto"), Raw(barSVG(values))),
|
||||
Div(Attr("class", "lg:col-span-5 rounded-default border border-neutral-200 bg-white p-3 shadow-xs overflow-auto"), Raw(pieSVG(values))),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const chartSnippet = `//gowasm:page /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()))),
|
||||
)
|
||||
}
|
||||
}`
|
||||
|
||||
@@ -74,26 +74,26 @@ func DataPage(d Deps) func() *VNode {
|
||||
fetchRepo(repoQuery.Get())
|
||||
}
|
||||
|
||||
return Div(Attr("class", "space-y-8"),
|
||||
Div(
|
||||
H2(Attr("class", "text-2xl font-semibold tracking-tight text-text-heading"), Text("Data fetching")),
|
||||
P(Attr("class", "mt-1 text-neutral-500"), Text("Two client-side fetches: gob from our own server, and JSON from a third-party API you choose.")),
|
||||
),
|
||||
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.",
|
||||
|
||||
ui.Card("",
|
||||
ui.CardHeader("", Text("gob — from our server")),
|
||||
P(Attr("class", "mb-3 text-sm text-neutral-500"),
|
||||
Text("The client GETs /api/quotes; the server responds with httputil.RespondGob "+
|
||||
"(a gob-encoded []Quote) and httputil.FetchGob decodes it straight into []Quote — "+
|
||||
"the same Go type on both ends, no JSON.")),
|
||||
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()),
|
||||
),
|
||||
),
|
||||
|
||||
ui.Card("",
|
||||
ui.CardHeader("", Text("JSON — from a third-party API")),
|
||||
P(Attr("class", "mb-3 text-sm text-neutral-500"),
|
||||
Text("Enter a GitHub repo; the client GETs api.github.com and httputil.FetchJSON "+
|
||||
"decodes the response into a Go struct with `json:\"…\"` tags.")),
|
||||
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)")),
|
||||
@@ -107,10 +107,45 @@ func DataPage(d Deps) func() *VNode {
|
||||
),
|
||||
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 != "":
|
||||
@@ -121,8 +156,8 @@ func quotesBody(loading bool, failed string, quotes []Quote) *VNode {
|
||||
cards := make([]*VNode, 0, len(quotes))
|
||||
for _, q := range quotes {
|
||||
cards = append(cards, ui.BorderCard("",
|
||||
P(Attr("class", "text-neutral-800"), Text("“"+q.Text+"”")),
|
||||
P(Attr("class", "mt-2 text-sm text-neutral-500"), Text("— "+q.Author)),
|
||||
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...)
|
||||
@@ -138,10 +173,10 @@ func repoBody(loading bool, failed string, r repoInfo) *VNode {
|
||||
default:
|
||||
return ui.BorderCard("",
|
||||
row("flex items-center gap-2",
|
||||
Strong(Attr("class", "text-neutral-800"), Text(r.FullName)),
|
||||
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-neutral-600"), Text(r.Description)),
|
||||
P(Attr("class", "mt-2 text-sm text-ink-soft"), Text(r.Description)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
276
go/cmd/examples/go-wasm-web/app/docs.go
Normal file
276
go/cmd/examples/go-wasm-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: "/docs", 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: "/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: "/server", Label: "Server components", Icon: "server",
|
||||
Blurb: "Components whose state and code stay on the server. Calling one looks like calling any other."},
|
||||
{Path: "/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: "/kit", Label: "UI kit", Icon: "squares",
|
||||
Blurb: "Buttons, forms, tabs, alerts, cards — the kjol/webui components, written in Go."},
|
||||
{Path: "/overlays", Label: "Overlays", Icon: "layers",
|
||||
Blurb: "Tooltips, popovers, menus, modals: measured against the real viewport, flipped and shifted to fit."},
|
||||
{Path: "/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 /docs 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 == "/docs" {
|
||||
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 /docs 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/examples/go-wasm-web/app/icons_test.go
Normal file
82
go/cmd/examples/go-wasm-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
|
||||
}
|
||||
@@ -7,6 +7,14 @@ import (
|
||||
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)}
|
||||
@@ -16,19 +24,17 @@ func row(class string, children ...*VNode) *VNode {
|
||||
return Div(mods...)
|
||||
}
|
||||
|
||||
// kitSection wraps a labeled demo block in a card.
|
||||
// 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 ui.Card("",
|
||||
ui.CardHeader("", Text(title)),
|
||||
row("flex flex-col gap-4", body...),
|
||||
)
|
||||
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-neutral-800", Text(name)),
|
||||
td("text-neutral-600", Text(plan)),
|
||||
td("text-ink", Text(name)),
|
||||
td("text-ink-soft", Text(plan)),
|
||||
El("td", Attr("class", "px-3 py-2 text-sm text-right"), status),
|
||||
)
|
||||
}
|
||||
@@ -67,14 +73,53 @@ func KitPage(d Deps) func() *VNode {
|
||||
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 Div(Attr("class", "space-y-8"),
|
||||
Div(
|
||||
H2(Attr("class", "text-2xl font-semibold tracking-tight text-text-heading"), Text("UI Kit")),
|
||||
P(Attr("class", "mt-1 text-neutral-500"),
|
||||
Text("The kjol/webui components, ported from the Solid.js kit and styled with Tailwind. "+
|
||||
"Interactive components are driven by signals; overlays and floating elements render "+
|
||||
"in their static form (see the note at the bottom).")),
|
||||
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",
|
||||
@@ -124,9 +169,9 @@ func KitPage(d Deps) func() *VNode {
|
||||
kitSection("Tabs",
|
||||
ui.TabGroup(ui.TabGroupProps{
|
||||
Items: []ui.TabItem{
|
||||
{Title: "Overview", Content: P(Attr("class", "pt-3 text-sm text-neutral-600"), Text("The overview panel."))},
|
||||
{Title: "Details", Content: P(Attr("class", "pt-3 text-sm text-neutral-600"), Text("The details panel."))},
|
||||
{Title: "Activity", Badge: 3, Content: P(Attr("class", "pt-3 text-sm text-neutral-600"), Text("The activity panel (3 new)."))},
|
||||
{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) },
|
||||
@@ -135,9 +180,9 @@ func KitPage(d Deps) func() *VNode {
|
||||
|
||||
kitSection("Accordion",
|
||||
ui.SingleAccordion([]ui.AccordionItemData{
|
||||
{Title: "What is gowasm?", Content: P(Attr("class", "text-sm text-neutral-600"), Text("A tiny Go→WebAssembly UI engine."))},
|
||||
{Title: "Is it isomorphic?", Content: P(Attr("class", "text-sm text-neutral-600"), 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-neutral-600"), Text("Tailwind utility classes, compiled by kjol's native Tailwind engine."))},
|
||||
{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) }),
|
||||
),
|
||||
|
||||
@@ -166,11 +211,68 @@ func KitPage(d Deps) func() *VNode {
|
||||
OnChange: func(v []string) { langs.Set(v) },
|
||||
})),
|
||||
),
|
||||
P(Attr("class", "text-xs text-neutral-500"),
|
||||
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{
|
||||
@@ -214,16 +316,40 @@ func KitPage(d Deps) func() *VNode {
|
||||
modal.Render(ui.ModalProps{
|
||||
Header: H3(Attr("class", "text-lg font-semibold text-text-heading"), Text("Example modal")),
|
||||
},
|
||||
P(Attr("class", "text-neutral-600"),
|
||||
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.")),
|
||||
),
|
||||
),
|
||||
|
||||
ui.Alert(ui.AlertGreen, "About this page",
|
||||
Text("Menus, tooltips, modals and dropdowns are now really measured: they are portaled to "+
|
||||
"document.body, positioned from getBoundingClientRect against the viewport, and they "+
|
||||
"flip and shift to stay on screen. Resize the window or scroll while one is open.")),
|
||||
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/examples/go-wasm-web/app/landing_test.go
Normal file
120
go/cmd/examples/go-wasm-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{}
|
||||
@@ -105,19 +105,34 @@ func OverlaysPage(d Deps) func() *VNode {
|
||||
})
|
||||
|
||||
return func() *VNode {
|
||||
return Div(Attr("class", "space-y-8"),
|
||||
Div(
|
||||
H2(Attr("class", "text-2xl font-semibold tracking-tight text-text-heading"), Text("Overlays")),
|
||||
P(Attr("class", "mt-1 text-neutral-500"),
|
||||
Text("Every floating component, really measured: portaled to document.body, positioned from "+
|
||||
"getBoundingClientRect against the viewport, flipping and shifting to stay on screen. "+
|
||||
"Scroll or resize the window with one open.")),
|
||||
row("mt-3 flex gap-2", tour.StartButton(0, "", Text("Take the tour"))),
|
||||
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 ----
|
||||
El("div", Attr("id", "demo-tooltips"),
|
||||
kitSection("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"})),
|
||||
@@ -128,34 +143,34 @@ func OverlaysPage(d Deps) func() *VNode {
|
||||
tipFocus.Render(Span(Text("Shown on focus, not hover — tab to the field")),
|
||||
ui.FormInput(ui.FormInputProps{Placeholder: "Focus me"})),
|
||||
),
|
||||
P(Attr("class", "text-xs text-neutral-500"),
|
||||
Text("Drag the window narrow and hover the Right one: it flips to the left, and its arrow "+
|
||||
"follows. Near an edge the panel shifts back on screen and the arrow slides to keep "+
|
||||
"pointing at the trigger — the original kit's arrow detached here.")),
|
||||
),
|
||||
),
|
||||
|
||||
// ---- popovers ----
|
||||
El("div", Attr("id", "demo-popovers"),
|
||||
kitSection("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-neutral-600"),
|
||||
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-neutral-600"), Text("Placement bottom-end.")),
|
||||
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-neutral-600"),
|
||||
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.")),
|
||||
),
|
||||
@@ -164,8 +179,14 @@ func OverlaysPage(d Deps) func() *VNode {
|
||||
),
|
||||
|
||||
// ---- menus ----
|
||||
El("div", Attr("id", "demo-menus"),
|
||||
kitSection("Menus & submenus",
|
||||
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 := " ▾"
|
||||
@@ -204,14 +225,15 @@ func OverlaysPage(d Deps) func() *VNode {
|
||||
hoverMenu.Item(ui.MenuItemProps{}, Text("Two")),
|
||||
),
|
||||
),
|
||||
P(Attr("class", "text-xs text-neutral-500"),
|
||||
Text("Opening one menu closes the other: a single-open manager, with submenus exempt "+
|
||||
"(Standalone), or a submenu would close its own parent.")),
|
||||
),
|
||||
),
|
||||
|
||||
// ---- date pickers ----
|
||||
kitSection("Date picker",
|
||||
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)")),
|
||||
@@ -222,13 +244,18 @@ func OverlaysPage(d Deps) func() *VNode {
|
||||
dob.Render(),
|
||||
),
|
||||
),
|
||||
P(Attr("class", "text-xs text-neutral-500"),
|
||||
Text("Picked: \""+picked.Get()+"\". Type into the field too — it parses loosely "+
|
||||
"(7/4/26, Jul 4 2026, 2026-07-04) and commits on blur.")),
|
||||
),
|
||||
),
|
||||
|
||||
// ---- modals ----
|
||||
kitSection("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}),
|
||||
@@ -241,23 +268,22 @@ func OverlaysPage(d Deps) 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-neutral-600"),
|
||||
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})
|
||||
}}),
|
||||
),
|
||||
P(Attr("class", "text-xs text-neutral-500"),
|
||||
Text("Deleted: "+strconv.FormatBool(deleted.Get())+
|
||||
". Open the modal, then the nested one inside it, and press Escape twice — "+
|
||||
"modals unwind one layer per press.")),
|
||||
),
|
||||
|
||||
// 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-neutral-600"),
|
||||
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.")),
|
||||
@@ -268,7 +294,7 @@ func OverlaysPage(d Deps) func() *VNode {
|
||||
nested.Render(ui.ModalProps{
|
||||
Header: H3(Attr("class", "text-lg font-semibold text-text-heading"), Text("Nested")),
|
||||
},
|
||||
P(Attr("class", "text-neutral-600"), Text("Escape closes THIS one first, not the one behind it.")),
|
||||
P(Attr("class", "text-ink-soft"), Text("Escape closes THIS one first, not the one behind it.")),
|
||||
),
|
||||
confirm.Confirm(ui.ConfirmModalProps{
|
||||
Title: "Delete row",
|
||||
@@ -306,7 +332,7 @@ func OverlaysPage(d Deps) func() *VNode {
|
||||
Title: "Confirm",
|
||||
Content: func(ctx ui.WizardStepContext) *VNode {
|
||||
ctx.SetCanContinue(true)
|
||||
return P(Attr("class", "text-neutral-600"),
|
||||
return P(Attr("class", "text-ink-soft"),
|
||||
Text("All set for "+wizardName.Get()+". Finish to close."))
|
||||
},
|
||||
},
|
||||
@@ -315,7 +341,13 @@ func OverlaysPage(d Deps) func() *VNode {
|
||||
),
|
||||
|
||||
// ---- toasts ----
|
||||
kitSection("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.") }}),
|
||||
@@ -334,11 +366,17 @@ func OverlaysPage(d Deps) func() *VNode {
|
||||
ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Small: true, Text: "Clear all",
|
||||
OnClick: toaster.Clear}),
|
||||
),
|
||||
P(Attr("class", "text-xs text-neutral-500"),
|
||||
Text("These auto-dismiss after 5 seconds — watch the bar count down; it is a CSS transition "+
|
||||
"driven straight at the DOM, not a re-render per frame. A sticky one (Duration: "+
|
||||
"ToastSticky) never leaves on its own. The menu items above raise toasts too, which is "+
|
||||
"how you can see that an item really does close its own menu.")),
|
||||
),
|
||||
),
|
||||
|
||||
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
|
||||
@@ -348,3 +386,21 @@ func OverlaysPage(d Deps) func() *VNode {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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.`
|
||||
|
||||
@@ -13,8 +13,14 @@ package app
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
@@ -24,6 +30,14 @@ type Deps struct {
|
||||
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,
|
||||
@@ -44,31 +58,59 @@ func Shell(d Deps, routes map[string]func() *VNode) *VNode {
|
||||
|
||||
func notFound(path string) *VNode {
|
||||
return Div(Attr("class", "py-10"),
|
||||
H2(Attr("class", "text-xl font-semibold text-neutral-800 mb-2"), Text("Page not found")),
|
||||
P(Attr("class", "text-neutral-500"), Text("No route matches "+path+".")),
|
||||
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 {
|
||||
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("Kjol Web")),
|
||||
Span(Attr("class", "text-sm text-ink-faint"), Text("Go + WASM")),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// 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(
|
||||
Nav(Attr("class", "site-nav sticky top-0 z-10 border-b border-neutral-200 bg-white"),
|
||||
Div(Attr("class", "mx-auto flex max-w-5xl items-center gap-2 px-4 py-3"),
|
||||
A(Attr("class", "text-lg font-semibold tracking-tight text-text-heading no-underline"), Attr("href", "/"), navigate(d, "/"), Text("gowasm")),
|
||||
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-2xl items-center gap-2 px-4 py-4"),
|
||||
wordmark(d, "/"),
|
||||
Ul(Attr("class", "ml-auto flex items-center gap-1"),
|
||||
navItem(d, "/", "Home", false),
|
||||
navItem(d, "/docs", "Docs", false),
|
||||
navItem(d, "/about", "About", false),
|
||||
Li(Attr("class", "ml-2"),
|
||||
A(Attr("class", "inline-flex items-center gap-1 rounded-default bg-primary px-3 py-1.5 text-sm font-medium text-white no-underline hover:bg-primary-hover"),
|
||||
Attr("href", "/chart"), navigate(d, "/chart"), Text("Open app →"))),
|
||||
Li(Attr("class", "ml-1"), Theme.ThemeToggle(ui.ThemeToggleProps{Small: true})),
|
||||
))),
|
||||
Main(Attr("class", "mx-auto max-w-5xl px-4 py-8"),
|
||||
content,
|
||||
Footer(Attr("class", "mt-12 border-t border-neutral-200 pt-4 text-sm text-neutral-400"),
|
||||
Text("gowasm — pure-Go components compiled to WebAssembly.")),
|
||||
|
||||
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 Web is part of kjol — a shared base layer. kjol is Norwegian for keel.")),
|
||||
),
|
||||
ui.ModalHost(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -77,30 +119,42 @@ func PublicLayout(d Deps, content *VNode) *VNode {
|
||||
// column; prose pages still are.
|
||||
var wideRoutes = map[string]bool{"/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 {
|
||||
width := "max-w-5xl"
|
||||
// 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()] {
|
||||
width = "max-w-[100rem]"
|
||||
// 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"),
|
||||
Nav(Attr("class", "app-nav border-b border-neutral-800 bg-neutral-900"),
|
||||
// The nav tracks the content's width, so the logo stays flush with the page
|
||||
// rather than floating in from the left on the wide routes.
|
||||
Div(Attr("class", "mx-auto flex "+width+" items-center gap-2 px-4 py-3"),
|
||||
A(Attr("class", "text-lg font-semibold tracking-tight text-text-on-dark no-underline"), Attr("href", "/chart"), navigate(d, "/chart"), Text("gowasm · app")),
|
||||
Ul(Attr("class", "ml-4 flex items-center gap-1"),
|
||||
navItem(d, "/chart", "Chart", true),
|
||||
navItem(d, "/server", "Server", true),
|
||||
navItem(d, "/data", "Data", true),
|
||||
navItem(d, "/table", "Table", true),
|
||||
navItem(d, "/overlays", "Overlays", true),
|
||||
navItem(d, "/kit", "UI Kit", true)),
|
||||
Ul(Attr("class", "ml-auto flex items-center"),
|
||||
navItem(d, "/", "Home", true)),
|
||||
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")),
|
||||
Ul(Attr("class", "ml-auto flex items-center gap-2"),
|
||||
navItem(d, "/", "Home", false),
|
||||
Li(Theme.ThemeToggle(ui.ThemeToggleProps{Small: true})),
|
||||
),
|
||||
)),
|
||||
Main(Attr("class", "mx-auto "+width+" px-4 py-8"), content),
|
||||
|
||||
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,
|
||||
@@ -109,6 +163,39 @@ func AppLayout(d Deps, content *VNode) *VNode {
|
||||
)
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -117,11 +204,11 @@ func navItem(d Deps, path, label string, dark bool) *VNode {
|
||||
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-neutral-300 hover:bg-white/5 hover:text-white"
|
||||
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-neutral-100 text-neutral-900"
|
||||
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-neutral-600 hover:bg-neutral-100 hover:text-neutral-900"
|
||||
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)))
|
||||
}
|
||||
@@ -139,8 +226,8 @@ func navigate(d Deps, path string) Mod {
|
||||
|
||||
// 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-neutral-200 bg-white px-4 py-3 shadow-xs"),
|
||||
Span(Attr("class", "font-medium text-neutral-700"), Text(label+": ")),
|
||||
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 }) }}),
|
||||
@@ -149,74 +236,268 @@ func Counter(label string, count *Signal[int]) *VNode {
|
||||
)
|
||||
}
|
||||
|
||||
// ---- 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 {
|
||||
a := NewSignal(0)
|
||||
b := NewSignal(0)
|
||||
dark := NewSignal(false)
|
||||
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 {
|
||||
return Div(Attr("class", "space-y-8"),
|
||||
markup := RenderHTML(demoTree())
|
||||
|
||||
return Div(Attr("class", "mx-auto max-w-2xl"),
|
||||
H1(Attr("class", "text-3xl font-semibold tracking-tight text-text-heading"),
|
||||
Text("Kjol Web")),
|
||||
P(Attr("class", "mt-3 leading-relaxed text-ink-soft"),
|
||||
Text("A small library for writing web interfaces in Go. Components are ordinary functions "+
|
||||
"returning a virtual DOM. The server renders them to HTML, and the same code compiles "+
|
||||
"to WebAssembly and takes over in the browser.")),
|
||||
P(Attr("class", "mt-3 leading-relaxed text-ink-soft"),
|
||||
Text("There is no JavaScript build step, and nothing outside the standard library.")),
|
||||
|
||||
// ---- the demonstration ----
|
||||
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(
|
||||
H2(Attr("class", "text-2xl font-semibold tracking-tight text-text-heading"), Text("Home — component composition")),
|
||||
P(Attr("class", "mt-1 text-neutral-500"), Text("Two counters; the total is derived across them. Server-rendered, then hydrated.")),
|
||||
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))),
|
||||
),
|
||||
Div(Attr("class", "grid gap-3 sm:grid-cols-2"),
|
||||
Counter("Apples", a),
|
||||
Counter("Bananas", b),
|
||||
),
|
||||
ui.Alert(ui.AlertBlue, "",
|
||||
Span(Text("Combined total: ")),
|
||||
Strong(Attr("class", "font-semibold"), Text(itoa(a.Get()+b.Get()))),
|
||||
),
|
||||
ui.Card("",
|
||||
ui.CardHeader("", Text("webui kit")),
|
||||
Div(Attr("class", "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, Outline: true, Text: "Danger"}),
|
||||
ui.Button(ui.ButtonProps{Color: ui.ButtonNeutral, Small: true, Icon: "check", Text: "Small"}),
|
||||
ui.Badge(ui.BadgeProps{Color: ui.BadgeGreen}, Text("active")),
|
||||
ui.Badge(ui.BadgeProps{Color: ui.BadgeAmber, Pill: true}, Text("pending")),
|
||||
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."),
|
||||
),
|
||||
Div(Attr("class", "mt-4"),
|
||||
ui.ToggleSwitch(dark.Get(), func(v bool) { dark.Set(v) }, "Dark mode", "Just a demo toggle", false, "")),
|
||||
|
||||
// ---- 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", "/docs"), navigate(d, "/docs"), Text("Read the docs")),
|
||||
Text(", or "),
|
||||
A(Attr("class", "text-accent underline underline-offset-4"),
|
||||
Attr("href", "/kit"), navigate(d, "/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.")),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
//gowasm:page /about static layout=public
|
||||
func AboutPage(d Deps) func() *VNode {
|
||||
return func() *VNode {
|
||||
return Div(Attr("class", "space-y-6"),
|
||||
H2(Attr("class", "text-2xl font-semibold tracking-tight text-text-heading"), Text("About")),
|
||||
ui.Card("",
|
||||
P(Attr("class", "text-neutral-600 leading-relaxed"),
|
||||
Text("Components are standalone Go functions; calling a server component looks "+
|
||||
"identical to calling a client one — the //gowasm:server directive and the "+
|
||||
"build-time codegen wire up the round-trip. Static routes are SSR'd; the rest "+
|
||||
"render on the client. The UI kit (kjol/webui) is a Go port of the Solid.js "+
|
||||
"component kit, styled with Tailwind.")),
|
||||
),
|
||||
ui.Alert(ui.AlertGreen, "Neutral + isomorphic",
|
||||
Text("This page's markup runs on the server (SSR) and hydrates on the client from the same Go code.")),
|
||||
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 /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 Div(Attr("class", "space-y-6"),
|
||||
H2(Attr("class", "text-2xl font-semibold tracking-tight text-text-heading"), Text("Server component")),
|
||||
P(Attr("class", "text-neutral-500"),
|
||||
Text("This counter runs on the server. Its state lives there; clicks round-trip "+
|
||||
"and the returned render merges into the DOM.")),
|
||||
counter(),
|
||||
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"),
|
||||
),
|
||||
)
|
||||
}
|
||||
}`
|
||||
|
||||
@@ -10,6 +10,7 @@ func Routes(d Deps) map[string]func() *vdom.VNode {
|
||||
"/about": AboutPage(d),
|
||||
"/chart": ChartPage(d),
|
||||
"/data": DataPage(d),
|
||||
"/docs": DocsPage(d),
|
||||
"/kit": KitPage(d),
|
||||
"/overlays": OverlaysPage(d),
|
||||
"/server": ServerPage(d),
|
||||
@@ -23,6 +24,7 @@ var StaticPaths = map[string]bool{
|
||||
"/about": true,
|
||||
"/chart": true,
|
||||
"/data": true,
|
||||
"/docs": true,
|
||||
"/table": true,
|
||||
}
|
||||
|
||||
@@ -32,6 +34,7 @@ var RouteLayout = map[string]string{
|
||||
"/about": "public",
|
||||
"/chart": "app",
|
||||
"/data": "app",
|
||||
"/docs": "app",
|
||||
"/kit": "app",
|
||||
"/overlays": "app",
|
||||
"/server": "app",
|
||||
|
||||
@@ -34,17 +34,20 @@ func ServerCounter() func() *VNode {
|
||||
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 ui.Card("",
|
||||
return Div(
|
||||
Div(Attr("class", "flex items-center gap-2 mb-3"),
|
||||
Span(Attr("class", "text-neutral-700"), Text("Server counter: ")),
|
||||
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-neutral-200 bg-white p-2 overflow-auto"),
|
||||
Div(Attr("class", "rounded-default border border-line bg-surface p-2 overflow-auto"),
|
||||
Raw(clickChartSVG(points.Get()))),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -48,12 +48,12 @@ func tableColumns() []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-neutral-800", Text(emp(r).Name)) },
|
||||
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-neutral-500", Text(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",
|
||||
@@ -162,7 +162,7 @@ func newEmployeeTable(highlight *Signal[string]) *ui.AutoTableState {
|
||||
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-neutral-600"), Text(emp(r).Note))
|
||||
return P(Attr("class", "px-4 py-2 text-sm text-ink-soft"), Text(emp(r).Note))
|
||||
},
|
||||
|
||||
Columns: ui.AutoTableColumnOptions{
|
||||
@@ -202,19 +202,37 @@ func TablePage(d Deps) func() *VNode {
|
||||
}
|
||||
return ui.AutoTablePDFHeader{
|
||||
Title: "Employees",
|
||||
Subtitle: "Exported from the gowasm example",
|
||||
Subtitle: "Exported from the Kjol Web example",
|
||||
ShowDate: true,
|
||||
Orientation: orientation,
|
||||
}
|
||||
}
|
||||
|
||||
return func() *VNode {
|
||||
return Div(Attr("class", "space-y-6"),
|
||||
Div(
|
||||
H2(Attr("class", "text-2xl font-semibold tracking-tight text-text-heading"), Text("AutoTable")),
|
||||
P(Attr("class", "mt-1 text-neutral-500"),
|
||||
Text("Filtering, sorting, pagination, expandable rows and column management — all in Go. "+
|
||||
"Drag a header to reorder, drag its right edge to resize; both persist across reloads.")),
|
||||
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(
|
||||
@@ -270,7 +288,7 @@ func TablePage(d Deps) func() *VNode {
|
||||
|
||||
// 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("flex flex-wrap items-center gap-2",
|
||||
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") }}),
|
||||
@@ -279,12 +297,59 @@ func TablePage(d Deps) func() *VNode {
|
||||
OnClick: func() { highlight.Set("") }}),
|
||||
),
|
||||
|
||||
ui.Alert(ui.AlertBlue, "What to try",
|
||||
Text("Search (it matches name OR email); pick a status; select several teams. Sort by Salary — "+
|
||||
"it parses the currency, so $980 sorts below $1,200.50. Unhide Rank and sort it: 'Item 2' "+
|
||||
"comes before 'Item 10'. Click a row to expand it. Drag a header to reorder, drag its right "+
|
||||
"edge to resize — both survive a reload. Filter the table, then export: you get every "+
|
||||
"matching row, not just this page. 'Find Radia' jumps to whichever page she is on.")),
|
||||
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`
|
||||
|
||||
@@ -54,13 +54,54 @@
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
Dark mode: `dark:` as a CLASS, not a media query.
|
||||
---------------------------------------------------------------------------
|
||||
Tailwind's built-in dark variant follows the operating system. A site with its own
|
||||
theme switch cannot use it: the OS says one thing, the switch says another, and the
|
||||
media query wins — so the switch appears to do nothing.
|
||||
|
||||
This redefines it against a class on <html>, which webui.Theme toggles. The OS is
|
||||
still respected: it is the DEFAULT (see the boot script in server/main.go), just no
|
||||
longer the last word.
|
||||
--------------------------------------------------------------------------- */
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
/* App-side design tokens the webui kit references (Tailwind v4 @theme). Brand
|
||||
values live with the app; the kit stays generic. */
|
||||
values live with the app; the kit stays generic.
|
||||
|
||||
The surface/line/ink tokens are the kit's THEME CONTRACT (see webui.ThemeTokens):
|
||||
components say bg-surface / border-line / text-ink and never name a colour, so the
|
||||
whole kit changes theme by changing these ten values rather than by carrying a dark:
|
||||
variant on four hundred class strings. */
|
||||
@theme {
|
||||
--radius-default: 0.375rem;
|
||||
|
||||
--color-primary: #4f46e5;
|
||||
--color-primary-hover: #4338ca;
|
||||
/* Sky: a cool, neutral blue. The page is mostly prose, code and tables, and the
|
||||
accent's job is to mark the few things you can act on — a saturated indigo or a
|
||||
primary blue competes with the content for attention instead of directing it. */
|
||||
--color-primary: #0284c7; /* sky-600 — accent FILLS; they carry white text */
|
||||
--color-primary-hover: #0369a1; /* sky-700 */
|
||||
--color-primary-subtle: #f0f9ff; /* sky-50 — tinted panels, badges, callouts */
|
||||
--color-primary-border: #bae6fd; /* sky-200 */
|
||||
|
||||
/* accent is for TEXT and icons. It is a separate token from primary because the two
|
||||
have opposite constraints: a fill must be dark enough for white text on top of it,
|
||||
and accent text must be readable ON the surface. One value cannot be both, and in
|
||||
dark mode they diverge completely. */
|
||||
--color-accent: #0369a1; /* sky-700 */
|
||||
|
||||
/* Surfaces, lines, ink — the kit's theme contract. */
|
||||
--color-surface: #ffffff;
|
||||
--color-surface-muted: #fafafa;
|
||||
--color-surface-raised: #f5f5f5;
|
||||
--color-surface-strong: #e5e5e5;
|
||||
--color-line: #e5e5e5;
|
||||
--color-line-strong: #d4d4d4;
|
||||
--color-ink: #171717;
|
||||
--color-ink-soft: #525252;
|
||||
--color-ink-muted: #737373;
|
||||
--color-ink-faint: #a3a3a3;
|
||||
|
||||
--color-text-heading: #111827;
|
||||
--color-text-on-dark: #f9fafb;
|
||||
@@ -73,3 +114,86 @@
|
||||
--font-sans: "Lora", ui-serif, Georgia, Cambria, "Times New Roman", serif;
|
||||
--font-serif: "Lora", ui-serif, Georgia, Cambria, "Times New Roman", serif;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
Grid background
|
||||
---------------------------------------------------------------------------
|
||||
Plain CSS, not a utility: Tailwind's arbitrary-value syntax cannot carry a
|
||||
background-image with commas in it without becoming unreadable, and this is a
|
||||
single named thing rather than a composition of atoms. kjol's Tailwind engine
|
||||
passes rules it does not recognise straight through, so this lands in the output
|
||||
untouched.
|
||||
|
||||
The grid is drawn with two 1px gradients — a vertical set and a horizontal set —
|
||||
tiled at --grid-size. It is deliberately faint: it should register as texture, not
|
||||
as graph paper you have to read the page through.
|
||||
--------------------------------------------------------------------------- */
|
||||
:root {
|
||||
--grid-line: rgba(15, 23, 42, 0.055);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
The dark theme.
|
||||
---------------------------------------------------------------------------
|
||||
Only the token VALUES change. Not one component knows this block exists — they ask
|
||||
for bg-surface and text-ink, and here is where those come to mean something else.
|
||||
|
||||
This is a plain rule, not another @theme block: @theme generates utilities, and these
|
||||
are overrides of utilities that already exist.
|
||||
|
||||
The surfaces are not pure black. Black gives a dark UI a hard, glaring edge against
|
||||
white text and makes every border invisible; a very dark grey leaves room for the
|
||||
raised surfaces and lines above it to actually be seen. --------------------------- */
|
||||
.dark {
|
||||
--color-surface: #101013;
|
||||
--color-surface-muted: #17171b;
|
||||
--color-surface-raised: #1f1f24;
|
||||
--color-surface-strong: #2c2c33;
|
||||
--color-line: #2a2a30;
|
||||
--color-line-strong: #3d3d45;
|
||||
--color-ink: #f2f2f3;
|
||||
--color-ink-soft: #c6c6cc;
|
||||
--color-ink-muted: #9a9aa3;
|
||||
--color-ink-faint: #71717a;
|
||||
|
||||
/* The fill stays put — white text has to remain readable on it — but accent TEXT has
|
||||
to climb to stay readable on a near-black surface. This is exactly why they are two
|
||||
tokens. */
|
||||
--color-accent: #7dd3fc; /* sky-300 */
|
||||
--color-primary-subtle: #0b2c3f;
|
||||
--color-primary-border: #0e4966;
|
||||
|
||||
--color-text-heading: #f5f5f5;
|
||||
|
||||
/* The grid is drawn in ink, not in shadow, once the page is dark. */
|
||||
--grid-line: rgba(226, 232, 240, 0.05);
|
||||
}
|
||||
|
||||
/* The page's own background — painted before the app mounts, and behind it afterwards.
|
||||
Without this, a dark app sits in a white window. */
|
||||
html {
|
||||
background-color: var(--color-surface);
|
||||
color: var(--color-ink);
|
||||
}
|
||||
|
||||
.bg-grid {
|
||||
background-image:
|
||||
linear-gradient(to right, var(--grid-line) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, var(--grid-line) 1px, transparent 1px);
|
||||
/* Written out rather than as var(--size) var(--size): the CSS minifier drops the
|
||||
space between two adjacent var() calls, and while that is still legal CSS, a
|
||||
background-size that depends on how a minifier tokenises is not worth the cleverness. */
|
||||
background-size: 56px 56px;
|
||||
background-position: center top;
|
||||
}
|
||||
|
||||
/* Fades the grid out towards the bottom, so it frames the hero and then gets out of
|
||||
the way of the content below rather than running under it the whole page. */
|
||||
.grid-fade {
|
||||
-webkit-mask-image: linear-gradient(to bottom, #000, #000 35%, transparent 100%);
|
||||
mask-image: linear-gradient(to bottom, #000, #000 35%, transparent 100%);
|
||||
}
|
||||
|
||||
/* (The hero glow that used to live here went with the hero. A coloured wash behind an
|
||||
oversized headline is the most recognisable gesture in framework marketing, and this
|
||||
page is not making that argument any more.) */
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"kjol/httputil"
|
||||
"kjol/vdom"
|
||||
"kjol/wasmdevserver"
|
||||
"kjol/webui"
|
||||
|
||||
"gowasmweb/app"
|
||||
"gowasmweb/buildsteps"
|
||||
@@ -73,15 +74,23 @@ func render(path string) (string, bool) {
|
||||
// between <div id="app"> and the markup, so hydration's childNodes line up. The
|
||||
// dev server injects the livereload script before </body> in watch mode.
|
||||
func document(inner string) string {
|
||||
// The theme boot script comes FIRST — before the stylesheet, before any markup.
|
||||
//
|
||||
// The server cannot read localStorage, so it cannot know which theme to render. If
|
||||
// the dark class were applied by the WebAssembly once it loads, a dark-mode user
|
||||
// would be shown a white page for as long as the binary takes to download and then
|
||||
// have it snatched away. This runs synchronously, before the first paint, so the
|
||||
// first paint is already right. It is the only JavaScript in the project.
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>gowasm — a tiny Blazor-like engine</title>
|
||||
<title>Kjol Web — Go + WASM</title>
|
||||
` + webui.ThemeBootScript + `
|
||||
<link rel="stylesheet" href="/app.css" />
|
||||
</head>
|
||||
<body class="bg-neutral-50 text-neutral-900 antialiased">
|
||||
<body class="bg-surface text-ink antialiased">
|
||||
<div id="app">` + inner + `</div>
|
||||
<script src="/wasm_exec.js"></script>
|
||||
<script src="/wasmboot.js"></script>
|
||||
|
||||
@@ -32,4 +32,10 @@ func main() {
|
||||
} else {
|
||||
wasmruntime.Run(render) // client-rendered route
|
||||
}
|
||||
|
||||
// Adopt the theme AFTER mounting. The class is already on <html> — the document's
|
||||
// boot script put it there before the first paint — so this is not what makes the
|
||||
// page dark; it is what makes the SWITCH know which way it is pointing, and what
|
||||
// keeps the page following the OS if the user never touched the switch.
|
||||
app.Theme.Init()
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
52
go/tw/custom_variant_test.go
Normal file
52
go/tw/custom_variant_test.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package tw
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// @custom-variant is how a project redefines `dark:` as a CLASS toggle. The built-in
|
||||
// dark variant is a prefers-color-scheme media query, which a site with its own theme
|
||||
// switch cannot use: the OS says one thing and the switch says another, and the media
|
||||
// query wins.
|
||||
func TestCustomVariantDarkClass(t *testing.T) {
|
||||
css, _, err := Compile(
|
||||
"@import \"tailwindcss\";\n@custom-variant dark (&:where(.dark, .dark *));\n",
|
||||
".", []string{"dark:bg-black", "bg-white"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(css, "prefers-color-scheme") {
|
||||
t.Error("dark: still compiled to a media query — the @custom-variant was ignored")
|
||||
}
|
||||
if !strings.Contains(css, ".dark") {
|
||||
t.Errorf("dark: did not compile to a class selector:\n%s", css)
|
||||
}
|
||||
}
|
||||
|
||||
// The shorthand's selector may itself contain commas — &:where(.dark, .dark *) is ONE
|
||||
// selector, and splitting it on that comma yields two broken halves.
|
||||
func TestCustomVariantKeepsNestedCommas(t *testing.T) {
|
||||
css, _, err := Compile(
|
||||
"@import \"tailwindcss\";\n@custom-variant dark (&:where(.dark, .dark *));\n",
|
||||
".", []string{"dark:bg-black"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(css, ".dark *") {
|
||||
t.Errorf("the descendant half of the selector was lost:\n%s", css)
|
||||
}
|
||||
}
|
||||
|
||||
// The block form, with an explicit @slot.
|
||||
func TestCustomVariantBlockForm(t *testing.T) {
|
||||
css, _, err := Compile(
|
||||
"@import \"tailwindcss\";\n@custom-variant tall {\n @media (min-height: 800px) { @slot; }\n}\n",
|
||||
".", []string{"tall:bg-black"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(css, "min-height: 800px") && !strings.Contains(css, "min-height:800px") {
|
||||
t.Errorf("the block-form variant did not compile:\n%s", css)
|
||||
}
|
||||
}
|
||||
@@ -1261,7 +1261,8 @@ func compileCandidates(rawCandidates []string, ds *DesignSystem, onInvalid func(
|
||||
// the candidate list.
|
||||
//
|
||||
// @INCOMPLETE Only static @utility blocks are supported (no functional
|
||||
// @utility/--value()); @custom-variant is not yet wired. -mta
|
||||
// @utility/--value()). @custom-variant IS wired (both the shorthand and block
|
||||
// forms) — see parseCustomVariant. -mta
|
||||
|
||||
//go:embed tw_theme.css
|
||||
var defaultThemeCSS string
|
||||
@@ -1304,6 +1305,7 @@ func twCompile(input, baseDir string, candidates []string) (string, int, error)
|
||||
var keyframes []*AstNode
|
||||
var passthrough []*AstNode
|
||||
var customUtilities []*AstNode
|
||||
var customVariants []*AstNode
|
||||
hasPreflight := false
|
||||
hasUtilities := false
|
||||
|
||||
@@ -1359,6 +1361,8 @@ func twCompile(input, baseDir string, candidates []string) (string, int, error)
|
||||
processTheme(node)
|
||||
case node.Kind == nAtRule && node.Name == "@utility":
|
||||
customUtilities = append(customUtilities, node)
|
||||
case node.Kind == nAtRule && node.Name == "@custom-variant":
|
||||
customVariants = append(customVariants, node)
|
||||
default:
|
||||
passthrough = append(passthrough, node)
|
||||
}
|
||||
@@ -1373,6 +1377,20 @@ func twCompile(input, baseDir string, candidates []string) (string, int, error)
|
||||
|
||||
ds := buildDesignSystem(theme)
|
||||
|
||||
// Register @custom-variant blocks. This is how a project defines `dark:` as a CLASS
|
||||
// toggle rather than a media query — the built-in dark variant follows the OS, which
|
||||
// a site with a theme switch cannot use:
|
||||
//
|
||||
// @custom-variant dark (&:where(.dark, .dark *));
|
||||
//
|
||||
// Both of Tailwind's forms are accepted: the shorthand above, and the block form
|
||||
// with an explicit @slot.
|
||||
for _, cv := range customVariants {
|
||||
if name, body, ok := parseCustomVariant(cv); ok {
|
||||
ds.variants.fromAst(name, body, ds)
|
||||
}
|
||||
}
|
||||
|
||||
// Register @utility blocks as static utilities.
|
||||
for _, u := range customUtilities {
|
||||
name := strings.TrimSpace(u.Params)
|
||||
@@ -8813,6 +8831,95 @@ func (v *Variants) compare(a, z *Variant) int {
|
||||
return 1
|
||||
}
|
||||
|
||||
// parseCustomVariant reads an @custom-variant at-rule into a name and the AST body that
|
||||
// fromAst expects (a body whose rules contain an @slot where the utility goes).
|
||||
//
|
||||
// Two forms, both from Tailwind:
|
||||
//
|
||||
// @custom-variant dark (&:where(.dark, .dark *)); // shorthand
|
||||
//
|
||||
// @custom-variant dark { // block, explicit slot
|
||||
// &:where(.dark, .dark *) { @slot; }
|
||||
// }
|
||||
//
|
||||
// In the shorthand, a parenthesised selector starting with '@' is an at-rule
|
||||
// (`@custom-variant any-hover (@media (any-hover: hover))`), and anything else is a
|
||||
// selector. Several may be given, comma-separated at the top level.
|
||||
func parseCustomVariant(node *AstNode) (name string, body []*AstNode, ok bool) {
|
||||
params := strings.TrimSpace(node.Params)
|
||||
if params == "" {
|
||||
return "", nil, false
|
||||
}
|
||||
|
||||
// The name is the first token; whatever follows is the shorthand's parenthesised part.
|
||||
i := strings.IndexAny(params, " \t(")
|
||||
if i < 0 {
|
||||
// No shorthand: it must be the block form, which carries its own @slot.
|
||||
if len(node.Nodes) == 0 {
|
||||
return "", nil, false
|
||||
}
|
||||
return params, node.Nodes, true
|
||||
}
|
||||
name = strings.TrimSpace(params[:i])
|
||||
rest := strings.TrimSpace(params[i:])
|
||||
|
||||
if rest == "" {
|
||||
if len(node.Nodes) == 0 {
|
||||
return "", nil, false
|
||||
}
|
||||
return name, node.Nodes, true
|
||||
}
|
||||
if !strings.HasPrefix(rest, "(") || !strings.HasSuffix(rest, ")") {
|
||||
return "", nil, false
|
||||
}
|
||||
inner := strings.TrimSpace(rest[1 : len(rest)-1])
|
||||
if inner == "" {
|
||||
return "", nil, false
|
||||
}
|
||||
|
||||
for _, sel := range splitTopLevel(inner, ',') {
|
||||
sel = strings.TrimSpace(sel)
|
||||
if sel == "" {
|
||||
continue
|
||||
}
|
||||
slot := atRule("@slot", "")
|
||||
if strings.HasPrefix(sel, "@") {
|
||||
// "@media (any-hover: hover)" -> name "@media", params "(any-hover: hover)"
|
||||
at, params, _ := strings.Cut(sel, " ")
|
||||
body = append(body, atRule(at, strings.TrimSpace(params), slot))
|
||||
continue
|
||||
}
|
||||
body = append(body, styleRule(sel, slot))
|
||||
}
|
||||
if len(body) == 0 {
|
||||
return "", nil, false
|
||||
}
|
||||
return name, body, true
|
||||
}
|
||||
|
||||
// splitTopLevel splits on sep, ignoring separators nested inside brackets — a selector
|
||||
// list like `&:where(.dark, .dark *)` is ONE selector, and splitting it on its inner
|
||||
// comma would produce two broken halves.
|
||||
func splitTopLevel(s string, sep byte) []string {
|
||||
var parts []string
|
||||
depth := 0
|
||||
start := 0
|
||||
for i := 0; i < len(s); i++ {
|
||||
switch s[i] {
|
||||
case '(', '[':
|
||||
depth++
|
||||
case ')', ']':
|
||||
depth--
|
||||
case sep:
|
||||
if depth == 0 {
|
||||
parts = append(parts, s[start:i])
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
return append(parts, s[start:])
|
||||
}
|
||||
|
||||
// fromAst registers a variant whose body comes from CSS (@custom-variant).
|
||||
func (v *Variants) fromAst(name string, ast []*AstNode, ds *DesignSystem) {
|
||||
var selectors []string
|
||||
|
||||
@@ -17,6 +17,15 @@ const (
|
||||
EVENT_MOUSEENTER = "mouseenter"
|
||||
EVENT_MOUSELEAVE = "mouseleave"
|
||||
EVENT_CONTEXTMENU = "contextmenu"
|
||||
|
||||
// Pointer events unify mouse, touch and pen behind one set of handlers that all
|
||||
// carry clientX/clientY. A component that wants to be drawn on — the signature pad —
|
||||
// needs exactly one code path, not a mouse one and a touch one that drift apart.
|
||||
EVENT_POINTERDOWN = "pointerdown"
|
||||
EVENT_POINTERMOVE = "pointermove"
|
||||
EVENT_POINTERUP = "pointerup"
|
||||
EVENT_POINTERCANCEL = "pointercancel"
|
||||
|
||||
EVENT_SCROLL = "scroll"
|
||||
EVENT_RESIZE = "resize"
|
||||
EVENT_WHEEL = "wheel"
|
||||
|
||||
@@ -13,6 +13,20 @@ func Measure(*vdom.Ref) Rect { return Rect{} }
|
||||
func Viewport() Size { return Size{} }
|
||||
func CSSVarPx(string) float64 { return 0 }
|
||||
|
||||
// Now is 0 on the server: there is no page load to measure from. A component that
|
||||
// reports a timing therefore SSRs an empty reading and fills it in once the client has
|
||||
// taken over — which is the honest answer, since the number IS a property of the client.
|
||||
func Now() float64 { return 0 }
|
||||
|
||||
// The theme is a property of the BROWSER — the OS setting and a localStorage choice,
|
||||
// neither of which the server can see. So the server always renders the light theme, and
|
||||
// the document's boot script (webui.ThemeBootScript) applies the dark class before the
|
||||
// first paint. Nothing here can help with that, and pretending otherwise would just move
|
||||
// the flash somewhere harder to find.
|
||||
func PrefersDark() bool { return false }
|
||||
func OnMediaChange(string, func(bool)) Unsub { return func() {} }
|
||||
func SetRootClass(string, bool) {}
|
||||
|
||||
func RAF(func()) int { return 0 }
|
||||
func CancelRAF(int) {}
|
||||
func AfterRender(func()) {}
|
||||
|
||||
@@ -67,8 +67,63 @@ func CSSVarPx(name string) float64 {
|
||||
return parseCSSLength(raw, style.Get("fontSize").String())
|
||||
}
|
||||
|
||||
// ---- theme ----
|
||||
|
||||
// PrefersDark reports whether the OS asks for a dark colour scheme.
|
||||
func PrefersDark() bool {
|
||||
mq := window.Call("matchMedia", "(prefers-color-scheme: dark)")
|
||||
return mq.Truthy() && mq.Get("matches").Bool()
|
||||
}
|
||||
|
||||
// OnMediaChange watches a media query. The theme controller uses it to follow the OS
|
||||
// while the user has not overridden it — a preference changed in the system settings
|
||||
// should reach an open tab, not wait for a reload.
|
||||
func OnMediaChange(query string, fn func(matches bool)) Unsub {
|
||||
mq := window.Call("matchMedia", query)
|
||||
if !mq.Truthy() {
|
||||
return func() {}
|
||||
}
|
||||
cb := js.FuncOf(func(_ js.Value, args []js.Value) any {
|
||||
matches := false
|
||||
if len(args) > 0 {
|
||||
matches = args[0].Get("matches").Bool()
|
||||
}
|
||||
fn(matches)
|
||||
return nil
|
||||
})
|
||||
mq.Call("addEventListener", "change", cb)
|
||||
return func() {
|
||||
mq.Call("removeEventListener", "change", cb)
|
||||
cb.Release()
|
||||
}
|
||||
}
|
||||
|
||||
// SetRootClass adds or removes a class on <html>.
|
||||
//
|
||||
// The THEME lives there rather than on the app's root element because the page's own
|
||||
// background — the thing behind everything, painted before the app mounts — is styled
|
||||
// from <html>. A dark class on #app leaves a white margin around a dark page.
|
||||
func SetRootClass(class string, on bool) {
|
||||
list := document.Get("documentElement").Get("classList")
|
||||
if on {
|
||||
list.Call("add", class)
|
||||
return
|
||||
}
|
||||
list.Call("remove", class)
|
||||
}
|
||||
|
||||
// ---- frame timing + the post-render hook ----
|
||||
|
||||
// Now is performance.now(): milliseconds since the page began navigating, with
|
||||
// sub-millisecond resolution.
|
||||
//
|
||||
// It is measured from the same origin the browser uses for its own timings, so a
|
||||
// reading taken when the first render commits IS the time it took this app to become
|
||||
// interactive — not an interval the app started and stopped itself.
|
||||
func Now() float64 {
|
||||
return window.Get("performance").Call("now").Float()
|
||||
}
|
||||
|
||||
func RAF(fn func()) int {
|
||||
var cb js.Func
|
||||
cb = js.FuncOf(func(js.Value, []js.Value) any {
|
||||
|
||||
@@ -11,14 +11,14 @@ import "kjol/vdom"
|
||||
// The TSX startOpen prop collapses into the caller-supplied initial IsOpen /
|
||||
// open[] / openIndex.
|
||||
|
||||
const accordionRootCls = "ui-accordion border border-neutral-200 rounded-default overflow-hidden"
|
||||
const accordionItemCls = "ui-accordion-item border-b border-neutral-200 last:border-b-0"
|
||||
const accordionTriggerCls = "ui-accordion-trigger flex items-center justify-between w-full py-3 px-4 text-left font-medium text-neutral-900 bg-neutral-50 cursor-pointer border-none transition-colors hover:bg-neutral-100 active:bg-neutral-200 focus:outline-hidden disabled:text-neutral-400 disabled:cursor-not-allowed disabled:bg-neutral-50"
|
||||
const accordionRootCls = "ui-accordion border border-line rounded-default overflow-hidden"
|
||||
const accordionItemCls = "ui-accordion-item border-b border-line last:border-b-0"
|
||||
const accordionTriggerCls = "ui-accordion-trigger flex items-center justify-between w-full py-3 px-4 text-left font-medium text-ink bg-surface-muted cursor-pointer border-none transition-colors hover:bg-surface-raised active:bg-surface-strong focus:outline-hidden disabled:text-ink-faint disabled:cursor-not-allowed disabled:bg-surface-muted"
|
||||
const accordionTitleCls = "ui-accordion-title flex-1"
|
||||
const accordionContentCls = "ui-accordion-content px-4 pb-4 text-neutral-700"
|
||||
const accordionContentCls = "ui-accordion-content px-4 pb-4 text-ink-soft"
|
||||
|
||||
func accordionIconCls(open bool) string {
|
||||
c := "ui-accordion-icon text-neutral-500 leading-none transition-transform duration-200"
|
||||
c := "ui-accordion-icon text-ink-muted leading-none transition-transform duration-200"
|
||||
if open {
|
||||
c += " rotate-180"
|
||||
}
|
||||
|
||||
@@ -17,12 +17,12 @@ const (
|
||||
const alertBase = "p-4 rounded-default shadow-xs border"
|
||||
|
||||
var alertColors = map[string]string{
|
||||
"white": "bg-white border-neutral-100",
|
||||
"gray": "bg-neutral-50 border-neutral-200",
|
||||
"blue": "bg-sky-50 border-sky-200",
|
||||
"green": "bg-green-50 border-green-200",
|
||||
"red": "bg-red-50 border-red-200",
|
||||
"yellow": "bg-yellow-50 border-yellow-200",
|
||||
"white": "bg-surface border-line",
|
||||
"gray": "bg-surface-muted border-line",
|
||||
"blue": "bg-sky-50 dark:bg-sky-950 border-sky-200 dark:border-sky-900",
|
||||
"green": "bg-green-50 dark:bg-green-950 border-green-200 dark:border-green-900",
|
||||
"red": "bg-red-50 dark:bg-red-950 border-red-200 dark:border-red-900",
|
||||
"yellow": "bg-yellow-50 dark:bg-yellow-950 border-yellow-200 dark:border-yellow-900",
|
||||
}
|
||||
|
||||
// Alert renders a colored callout. header is optional (rendered as a bold title
|
||||
|
||||
@@ -80,7 +80,7 @@ const (
|
||||
|
||||
// HEADER_COLOR_CLS is the background + text color per header color.
|
||||
var HEADER_COLOR_CLS = map[AutoTableHeaderColor]string{
|
||||
AUTOTABLE_HEADER_COLOR_DEFAULT: "bg-neutral-50",
|
||||
AUTOTABLE_HEADER_COLOR_DEFAULT: "bg-surface-muted",
|
||||
AUTOTABLE_HEADER_COLOR_BLUE: "bg-sky-700 text-white",
|
||||
AUTOTABLE_HEADER_COLOR_GREEN: "bg-green-700 text-white",
|
||||
AUTOTABLE_HEADER_COLOR_GRAY: "bg-neutral-600 text-white",
|
||||
@@ -89,7 +89,7 @@ var HEADER_COLOR_CLS = map[AutoTableHeaderColor]string{
|
||||
|
||||
// atHeaderSortHoverCls is the sortable-hover override per color.
|
||||
var atHeaderSortHoverCls = map[AutoTableHeaderColor]string{
|
||||
AUTOTABLE_HEADER_COLOR_DEFAULT: "hover:bg-neutral-300",
|
||||
AUTOTABLE_HEADER_COLOR_DEFAULT: "hover:bg-surface-strong",
|
||||
AUTOTABLE_HEADER_COLOR_BLUE: "hover:bg-sky-500",
|
||||
AUTOTABLE_HEADER_COLOR_GREEN: "hover:bg-green-800",
|
||||
AUTOTABLE_HEADER_COLOR_GRAY: "hover:bg-neutral-500",
|
||||
@@ -107,7 +107,7 @@ var HEADER_TEXT_CLS = map[AutoTableHeaderColor]string{
|
||||
|
||||
// atHeaderSortIconCls is the sort-icon color (matches header text) per color.
|
||||
var atHeaderSortIconCls = map[AutoTableHeaderColor]string{
|
||||
AUTOTABLE_HEADER_COLOR_DEFAULT: "text-black",
|
||||
AUTOTABLE_HEADER_COLOR_DEFAULT: "text-ink",
|
||||
AUTOTABLE_HEADER_COLOR_BLUE: "text-white",
|
||||
AUTOTABLE_HEADER_COLOR_GREEN: "text-white",
|
||||
AUTOTABLE_HEADER_COLOR_GRAY: "text-white",
|
||||
@@ -137,11 +137,11 @@ var atPaginationPaddingCls = map[AutoTableSize]string{
|
||||
|
||||
// atRowHoverCls is the body-row hover background per color (when hover is on).
|
||||
var atRowHoverCls = map[AutoTableHeaderColor]string{
|
||||
AUTOTABLE_HEADER_COLOR_DEFAULT: "hover:bg-neutral-200",
|
||||
AUTOTABLE_HEADER_COLOR_BLUE: "hover:bg-sky-100",
|
||||
AUTOTABLE_HEADER_COLOR_GREEN: "hover:bg-green-100",
|
||||
AUTOTABLE_HEADER_COLOR_GRAY: "hover:bg-neutral-200",
|
||||
AUTOTABLE_HEADER_COLOR_DARK_BLUE: "hover:bg-sky-100",
|
||||
AUTOTABLE_HEADER_COLOR_DEFAULT: "hover:bg-surface-strong",
|
||||
AUTOTABLE_HEADER_COLOR_BLUE: "hover:bg-sky-100 dark:bg-sky-900",
|
||||
AUTOTABLE_HEADER_COLOR_GREEN: "hover:bg-green-100 dark:bg-green-900",
|
||||
AUTOTABLE_HEADER_COLOR_GRAY: "hover:bg-surface-strong",
|
||||
AUTOTABLE_HEADER_COLOR_DARK_BLUE: "hover:bg-sky-100 dark:bg-sky-900",
|
||||
}
|
||||
|
||||
// POS_CLS is the text alignment per ColumnPosition.
|
||||
@@ -160,7 +160,7 @@ var HEADER_INNER_POS = map[ColumnPosition]string{
|
||||
|
||||
// -- Class string constants for parts that don't vary by config ------------
|
||||
const (
|
||||
TBL_CONTAINER = "relative flex flex-col w-full h-full bg-white rounded-default overflow-hidden"
|
||||
TBL_CONTAINER = "relative flex flex-col w-full h-full bg-surface rounded-default overflow-hidden"
|
||||
TBL_WRAPPER = "overflow-x-auto w-full"
|
||||
TBL_BASE = "min-w-full"
|
||||
|
||||
@@ -169,20 +169,20 @@ const (
|
||||
|
||||
atSortIconWrap = "leading-none shrink-0 opacity-50"
|
||||
|
||||
atSkeleton = "h-4 bg-neutral-200 rounded-default animate-pulse"
|
||||
atErrorCell = "text-center text-red-600"
|
||||
atEmptyCell = "text-center text-neutral-500"
|
||||
atSkeleton = "h-4 bg-surface-strong rounded-default animate-pulse"
|
||||
atErrorCell = "text-center text-red-600 dark:text-red-400"
|
||||
atEmptyCell = "text-center text-ink-muted"
|
||||
|
||||
atPaginationBase = "flex justify-between items-center border-t border-neutral-300"
|
||||
atPaginationInfo = "hidden sm:flex items-center text-sm text-neutral-500"
|
||||
atPaginationBase = "flex justify-between items-center border-t border-line-strong"
|
||||
atPaginationInfo = "hidden sm:flex items-center text-sm text-ink-muted"
|
||||
atPaginationControls = "flex items-center"
|
||||
atPaginationLabel = "hidden sm:block text-sm text-neutral-500 mr-2"
|
||||
atPaginationPage = "text-sm text-neutral-500 px-3"
|
||||
atPaginationBtn = "p-1 min-h-9 text-sm font-normal leading-none bg-transparent border-0 cursor-pointer hover:bg-neutral-100 disabled:text-neutral-300 disabled:cursor-not-allowed disabled:hover:bg-transparent"
|
||||
atPaginationLabel = "hidden sm:block text-sm text-ink-muted mr-2"
|
||||
atPaginationPage = "text-sm text-ink-muted px-3"
|
||||
atPaginationBtn = "p-1 min-h-9 text-sm font-normal leading-none bg-transparent border-0 cursor-pointer hover:bg-surface-raised disabled:text-ink-faint disabled:cursor-not-allowed disabled:hover:bg-transparent"
|
||||
|
||||
// thead sticky classes are the STATIC replacement for the TSX's JS-driven
|
||||
// header pinning (transform tracking on scroll). See file-level NOTE.
|
||||
atTheadCls = "sticky top-0 z-10 [&_th]:border-b [&_th]:border-neutral-300"
|
||||
atTheadCls = "sticky top-0 z-10 [&_th]:border-b [&_th]:border-line-strong"
|
||||
)
|
||||
|
||||
// AutoTableColumn describes one column: its header, alignment, width, whether it
|
||||
@@ -475,7 +475,7 @@ func AutoTable(cols []AutoTableColumn, rows []any, opts ...AutoTableOption) *vdo
|
||||
|
||||
containerCls := TBL_CONTAINER
|
||||
if cfg.surroundingBorder {
|
||||
containerCls = cx(containerCls, "border border-neutral-300")
|
||||
containerCls = cx(containerCls, "border border-line-strong")
|
||||
}
|
||||
if cfg.shadow {
|
||||
containerCls = cx(containerCls, "shadow-sm")
|
||||
@@ -754,7 +754,7 @@ func atResizeHandle(key string, h *atColumnHooks) *vdom.VNode {
|
||||
func atRenderBody(cols []AutoTableColumn, rows []any, cfg *atConfig) *vdom.VNode {
|
||||
bodyCls := BODY_PADDING_CLS[cfg.size]
|
||||
if cfg.borderY {
|
||||
bodyCls = cx(bodyCls, "[&_td+td]:border-l [&_td+td]:border-neutral-300")
|
||||
bodyCls = cx(bodyCls, "[&_td+td]:border-l [&_td+td]:border-line-strong")
|
||||
}
|
||||
|
||||
colspan := atTotalColumns(cols, cfg)
|
||||
@@ -768,7 +768,7 @@ func atRenderBody(cols []AutoTableColumn, rows []any, cfg *atConfig) *vdom.VNode
|
||||
for r := 0; r < 5; r++ {
|
||||
tr := vdom.Tr()
|
||||
if cfg.alternate && r%2 == 1 {
|
||||
tr.Attrs["class"] = "bg-neutral-100"
|
||||
tr.Attrs["class"] = "bg-surface-raised"
|
||||
}
|
||||
if cfg.accordion {
|
||||
tr.Children = append(tr.Children, vdom.Td(vdom.Attr("class", AUTOTABLE_ACCORDION_CELL)))
|
||||
@@ -799,11 +799,11 @@ func atRenderBody(cols []AutoTableColumn, rows []any, cfg *atConfig) *vdom.VNode
|
||||
isLast := rowIdx == len(rows)-1
|
||||
rowCls := ""
|
||||
if cfg.alternate && rowIdx%2 == 1 {
|
||||
rowCls = cx(rowCls, "bg-neutral-100")
|
||||
rowCls = cx(rowCls, "bg-surface-raised")
|
||||
}
|
||||
rowCls = cx(rowCls, hoverCls)
|
||||
if cfg.borderX && !isLast {
|
||||
rowCls = cx(rowCls, "border-b border-neutral-300")
|
||||
rowCls = cx(rowCls, "border-b border-line-strong")
|
||||
}
|
||||
if cfg.highlight != nil && cfg.highlight(row) {
|
||||
rowCls = cx(rowCls, AUTOTABLE_HIGHLIGHT_ROW)
|
||||
@@ -2182,14 +2182,14 @@ func (s *AutoTableState) FiltersToggle() *vdom.VNode {
|
||||
const (
|
||||
AUTOTABLE_SEARCH_FIELDS = "flex flex-wrap items-end gap-2"
|
||||
AUTOTABLE_SEARCH_FIELD = "flex flex-col gap-1 min-w-0 grow sm:grow-0 sm:w-48"
|
||||
AUTOTABLE_SEARCH_SELECT = "w-full rounded-default border border-neutral-300 bg-white px-3 py-2 text-sm"
|
||||
AUTOTABLE_FILTERS_TOGGLE = "sm:hidden inline-flex items-center gap-2 rounded-default border border-neutral-300 px-3 py-2 text-sm"
|
||||
AUTOTABLE_SEARCH_SELECT = "w-full rounded-default border border-line-strong bg-surface px-3 py-2 text-sm"
|
||||
AUTOTABLE_FILTERS_TOGGLE = "sm:hidden inline-flex items-center gap-2 rounded-default border border-line-strong px-3 py-2 text-sm"
|
||||
AUTOTABLE_FILTERS_BADGE = "ml-1 inline-flex h-5 min-w-5 items-center justify-center rounded-full bg-sky-600 px-1.5 text-xs font-semibold text-white"
|
||||
AUTOTABLE_TOOLBAR = "flex flex-wrap items-end justify-between gap-3 pb-3"
|
||||
AUTOTABLE_ASIDE = "w-full sm:w-64 shrink-0 rounded-default border border-neutral-300 bg-white p-3"
|
||||
AUTOTABLE_ASIDE = "w-full sm:w-64 shrink-0 rounded-default border border-line-strong bg-surface p-3"
|
||||
AUTOTABLE_ACCORDION_CELL = "w-10 text-center"
|
||||
AUTOTABLE_ACCORDION_ROW = "bg-neutral-50"
|
||||
AUTOTABLE_HIGHLIGHT_ROW = "bg-amber-100! shadow-[inset_3px_0_0_0_theme(colors.amber.500)]"
|
||||
AUTOTABLE_ACCORDION_ROW = "bg-surface-muted"
|
||||
AUTOTABLE_HIGHLIGHT_ROW = "bg-amber-100 dark:bg-amber-900! shadow-[inset_3px_0_0_0_theme(colors.amber.500)]"
|
||||
)
|
||||
|
||||
// ==========================================================================
|
||||
@@ -2602,7 +2602,7 @@ func (s *AutoTableState) pendingSkeleton() *vdom.VNode {
|
||||
vdom.Attr("aria-label", "Loading table"),
|
||||
vdom.Div(
|
||||
kids([]vdom.Mod{
|
||||
vdom.Attr("class", cx(TBL_CONTAINER, "border border-neutral-300 p-4 flex flex-col gap-3")),
|
||||
vdom.Attr("class", cx(TBL_CONTAINER, "border border-line-strong p-4 flex flex-col gap-3")),
|
||||
}, bars)...,
|
||||
),
|
||||
)
|
||||
@@ -2718,6 +2718,7 @@ func (s *AutoTableState) ColumnPicker() *vdom.VNode {
|
||||
if len(options) == 0 {
|
||||
return nil
|
||||
}
|
||||
total := len(options)
|
||||
return s.multiSelect("__columns__").Render(FormMultiSelectProps{
|
||||
Options: options,
|
||||
Value: selected,
|
||||
@@ -2725,6 +2726,11 @@ func (s *AutoTableState) ColumnPicker() *vdom.VNode {
|
||||
Searchable: true,
|
||||
ShowSelectAll: true,
|
||||
FieldWidth: "w-52",
|
||||
// Not "5 items selected": this picker is about COLUMNS, and the count is only
|
||||
// meaningful next to how many there are to choose from.
|
||||
CollapsedLabel: func(n int) string {
|
||||
return strconv.Itoa(n) + " of " + strconv.Itoa(total) + " columns"
|
||||
},
|
||||
OnChange: func(visible []string) {
|
||||
show := map[string]bool{}
|
||||
for _, k := range visible {
|
||||
@@ -2779,7 +2785,7 @@ func (s *AutoTableState) ResetMenu() *vdom.VNode {
|
||||
}
|
||||
|
||||
if len(items) == 0 {
|
||||
items = append(items, vdom.Div(vdom.Attr("class", "px-3 py-2 text-sm text-neutral-500"),
|
||||
items = append(items, vdom.Div(vdom.Attr("class", "px-3 py-2 text-sm text-ink-muted"),
|
||||
vdom.Text("Nothing to reset"),
|
||||
))
|
||||
} else {
|
||||
@@ -3091,7 +3097,7 @@ func (s *AutoTableState) summaryFoot(ctx *CalcContext, colCount int) *vdom.VNode
|
||||
}
|
||||
|
||||
// AUTOTABLE_TFOOT styles the summary footer.
|
||||
const AUTOTABLE_TFOOT = "border-t-2 border-neutral-300 bg-neutral-50 [&_td]:p-3"
|
||||
const AUTOTABLE_TFOOT = "border-t-2 border-line-strong bg-surface-muted [&_td]:p-3"
|
||||
|
||||
// ---- the calculated-column editor ----
|
||||
|
||||
@@ -3127,16 +3133,16 @@ func isBinaryCalcFn(fn CalculatedFunction) bool {
|
||||
// their metrics line up to the pixel; if they ever drift, the caret stops landing on
|
||||
// the glyph it appears to be on.
|
||||
const (
|
||||
CALC_ADD_TRIGGER_CLS = "inline-flex items-center justify-center gap-2 cursor-pointer text-sm font-normal rounded-default transition shadow-xs bg-neutral-50 text-black border border-neutral-300 hover:bg-neutral-100 py-1 px-3"
|
||||
CALC_ADD_TRIGGER_CLS = "inline-flex items-center justify-center gap-2 cursor-pointer text-sm font-normal rounded-default transition shadow-xs bg-surface-muted text-ink border border-line-strong hover:bg-surface-raised py-1 px-3"
|
||||
|
||||
FORMULA_EDIT_BASE = "block w-full h-[30px] font-mono text-sm p-1 rounded-default box-border whitespace-pre"
|
||||
FORMULA_OVERLAY_CLS = FORMULA_EDIT_BASE + " absolute inset-0 overflow-hidden pointer-events-none border border-transparent text-neutral-800"
|
||||
FORMULA_TEXTAREA_CLS = FORMULA_EDIT_BASE + " relative bg-transparent text-transparent caret-neutral-800 resize-none overflow-x-auto overflow-y-hidden shadow-xs border border-neutral-300 focus:border-sky-500 outline-hidden"
|
||||
FORMULA_OVERLAY_CLS = FORMULA_EDIT_BASE + " absolute inset-0 overflow-hidden pointer-events-none border border-transparent text-ink"
|
||||
FORMULA_TEXTAREA_CLS = FORMULA_EDIT_BASE + " relative bg-transparent text-transparent caret-neutral-800 resize-none overflow-x-auto overflow-y-hidden shadow-xs border border-line-strong focus:border-sky-500 outline-hidden"
|
||||
|
||||
formulaPlaceholder = `<span class="text-neutral-400">e.g. [Revenue] / SUM({Revenue}) * 100</span>`
|
||||
formulaPlaceholder = `<span class="text-ink-faint">e.g. [Revenue] / SUM({Revenue}) * 100</span>`
|
||||
|
||||
calcMenuTrigger = "inline-flex items-center gap-1 rounded-default border border-neutral-300 bg-neutral-50 px-2 py-1 text-xs leading-none text-neutral-700 cursor-pointer hover:bg-neutral-100"
|
||||
calcChooserItem = "w-full text-left px-2 py-2 rounded-default hover:bg-neutral-100 cursor-pointer border-0 bg-transparent flex items-start gap-2.5"
|
||||
calcMenuTrigger = "inline-flex items-center gap-1 rounded-default border border-line-strong bg-surface-muted px-2 py-1 text-xs leading-none text-ink-soft cursor-pointer hover:bg-surface-raised"
|
||||
calcChooserItem = "w-full text-left px-2 py-2 rounded-default hover:bg-surface-raised cursor-pointer border-0 bg-transparent flex items-start gap-2.5"
|
||||
)
|
||||
|
||||
// HighlightFormula renders a formula as HTML with syntax colouring: cell refs
|
||||
@@ -3145,6 +3151,20 @@ const (
|
||||
// Purely visual, and deliberately LEXICAL rather than a real parse — it has to
|
||||
// colour a half-typed formula that does not compile yet, which is exactly when the
|
||||
// colours are worth the most.
|
||||
// The formula highlighter's token colours.
|
||||
//
|
||||
// Named, rather than written inline, because they are asserted on in tests — and a
|
||||
// colour spelled out in two places is a colour that gets changed in one. (It already
|
||||
// happened: adding the dark: variants broke every highlighter test, none of which cared
|
||||
// about the colour, all of which had a copy of it.)
|
||||
const (
|
||||
fmlCellClass = "text-sky-600 dark:text-sky-400" // [Cell]
|
||||
fmlColumnClass = "text-violet-600 dark:text-violet-400" // {Column}
|
||||
fmlNumberClass = "text-amber-600 dark:text-amber-400" // 42, PI
|
||||
fmlFuncClass = "text-emerald-700 dark:text-emerald-400 font-semibold" // SUM(
|
||||
fmlOpClass = "text-ink-faint" // + - * /
|
||||
)
|
||||
|
||||
func HighlightFormula(src string) string {
|
||||
var b strings.Builder
|
||||
i := 0
|
||||
@@ -3159,7 +3179,7 @@ func HighlightFormula(src string) string {
|
||||
if j >= 0 {
|
||||
end = i + j + 1
|
||||
}
|
||||
b.WriteString(`<span class="text-sky-600">`)
|
||||
b.WriteString(`<span class="` + fmlCellClass + `">`)
|
||||
b.WriteString(html.EscapeString(src[i:end]))
|
||||
b.WriteString(`</span>`)
|
||||
i = end
|
||||
@@ -3176,7 +3196,7 @@ func HighlightFormula(src string) string {
|
||||
}
|
||||
j++
|
||||
}
|
||||
b.WriteString(`<span class="text-violet-600">`)
|
||||
b.WriteString(`<span class="` + fmlColumnClass + `">`)
|
||||
b.WriteString(html.EscapeString(src[i:j]))
|
||||
b.WriteString(`</span>`)
|
||||
i = j
|
||||
@@ -3186,7 +3206,7 @@ func HighlightFormula(src string) string {
|
||||
for j < len(src) && (isDigit(src[j]) || src[j] == '.') {
|
||||
j++
|
||||
}
|
||||
b.WriteString(`<span class="text-amber-600">`)
|
||||
b.WriteString(`<span class="` + fmlNumberClass + `">`)
|
||||
b.WriteString(html.EscapeString(src[i:j]))
|
||||
b.WriteString(`</span>`)
|
||||
i = j
|
||||
@@ -3205,11 +3225,11 @@ func HighlightFormula(src string) string {
|
||||
}
|
||||
switch {
|
||||
case k < len(src) && src[k] == '(':
|
||||
b.WriteString(`<span class="text-emerald-700 font-semibold">`)
|
||||
b.WriteString(`<span class="` + fmlFuncClass + `">`)
|
||||
b.WriteString(html.EscapeString(word))
|
||||
b.WriteString(`</span>`)
|
||||
case isFormulaConstant(word):
|
||||
b.WriteString(`<span class="text-amber-600">`)
|
||||
b.WriteString(`<span class="` + fmlNumberClass + `">`)
|
||||
b.WriteString(html.EscapeString(word))
|
||||
b.WriteString(`</span>`)
|
||||
default:
|
||||
@@ -3218,7 +3238,7 @@ func HighlightFormula(src string) string {
|
||||
i = j
|
||||
|
||||
case strings.IndexByte("+-*/^%=<>(),:", c) >= 0:
|
||||
b.WriteString(`<span class="text-neutral-400">`)
|
||||
b.WriteString(`<span class="` + fmlOpClass + `">`)
|
||||
b.WriteString(html.EscapeString(string(c)))
|
||||
b.WriteString(`</span>`)
|
||||
i++
|
||||
@@ -3590,7 +3610,7 @@ func (s *AutoTableState) calcChooser(e *calcEditor) *vdom.VNode {
|
||||
))
|
||||
}
|
||||
if len(existing) > 0 {
|
||||
children = append(children, vdom.Div(kids([]vdom.Mod{vdom.Attr("class", "flex flex-col gap-1 border-b border-neutral-200 pb-2 mb-1")}, existing)...))
|
||||
children = append(children, vdom.Div(kids([]vdom.Mod{vdom.Attr("class", "flex flex-col gap-1 border-b border-line pb-2 mb-1")}, existing)...))
|
||||
}
|
||||
|
||||
children = append(children,
|
||||
@@ -3607,19 +3627,19 @@ func calcChooserButton(icon, title, subtitle string, onClick func()) *vdom.VNode
|
||||
return vdom.Button(vdom.Attr("type", "button"),
|
||||
vdom.Attr("class", calcChooserItem),
|
||||
vdom.On(vdom.EVENT_CLICK, onClick),
|
||||
Icon(icon, 16, "mt-0.5 text-neutral-500"),
|
||||
Icon(icon, 16, "mt-0.5 text-ink-muted"),
|
||||
vdom.Span(vdom.Attr("class", "flex flex-col"),
|
||||
vdom.Span(vdom.Attr("class", "text-sm font-medium text-neutral-800"), vdom.Text(title)),
|
||||
vdom.Span(vdom.Attr("class", "text-xs text-neutral-500"), vdom.Text(subtitle)),
|
||||
vdom.Span(vdom.Attr("class", "text-sm font-medium text-ink"), vdom.Text(title)),
|
||||
vdom.Span(vdom.Attr("class", "text-xs text-ink-muted"), vdom.Text(subtitle)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func calcListRow(title, subtitle string, onEdit, onDelete func()) *vdom.VNode {
|
||||
return vdom.Div(vdom.Attr("class", "flex items-center gap-2 rounded-default px-2 py-1 hover:bg-neutral-50"),
|
||||
return vdom.Div(vdom.Attr("class", "flex items-center gap-2 rounded-default px-2 py-1 hover:bg-surface-muted"),
|
||||
vdom.Div(vdom.Attr("class", "min-w-0 grow"),
|
||||
vdom.Div(vdom.Attr("class", "truncate text-sm font-medium text-neutral-800"), vdom.Text(title)),
|
||||
vdom.Div(vdom.Attr("class", "truncate font-mono text-xs text-neutral-500"), vdom.Text(subtitle)),
|
||||
vdom.Div(vdom.Attr("class", "truncate text-sm font-medium text-ink"), vdom.Text(title)),
|
||||
vdom.Div(vdom.Attr("class", "truncate font-mono text-xs text-ink-muted"), vdom.Text(subtitle)),
|
||||
),
|
||||
Button(ButtonProps{Color: ButtonLightNeutral, Small: true, Icon: "pencil", Title: "Edit", OnClick: onEdit}),
|
||||
Button(ButtonProps{Color: ButtonLightNeutral, Small: true, Icon: "trash", Title: "Delete", OnClick: onDelete}),
|
||||
@@ -3712,14 +3732,14 @@ func (s *AutoTableState) calcForm(e *calcEditor, summary bool) *vdom.VNode {
|
||||
title = "Edit " + strings.ToLower(title)
|
||||
}
|
||||
|
||||
header := vdom.Div(vdom.Attr("class", "flex items-center gap-2 border-b border-neutral-200 px-3 py-2"),
|
||||
header := vdom.Div(vdom.Attr("class", "flex items-center gap-2 border-b border-line px-3 py-2"),
|
||||
vdom.Button(vdom.Attr("type", "button"),
|
||||
vdom.Attr("class", "shrink-0 cursor-pointer rounded-default p-1 text-neutral-500 hover:bg-neutral-100 border-0 bg-transparent"),
|
||||
vdom.Attr("class", "shrink-0 cursor-pointer rounded-default p-1 text-ink-muted hover:bg-surface-raised border-0 bg-transparent"),
|
||||
vdom.Attr("aria-label", "Back"),
|
||||
vdom.On(vdom.EVENT_CLICK, e.reset),
|
||||
Icon("chevron-left", 14, ""),
|
||||
),
|
||||
vdom.Span(vdom.Attr("class", "text-sm font-medium text-neutral-800"), vdom.Text(title)),
|
||||
vdom.Span(vdom.Attr("class", "text-sm font-medium text-ink"), vdom.Text(title)),
|
||||
// Basic vs Advanced.
|
||||
vdom.Div(vdom.Attr("class", "ml-auto"),
|
||||
SegmentedButtons([]SegmentedButtonOption{
|
||||
@@ -3773,7 +3793,7 @@ func (s *AutoTableState) calcForm(e *calcEditor, summary bool) *vdom.VNode {
|
||||
}
|
||||
|
||||
if msg := e.errorMsg.Get(); msg != "" {
|
||||
fields = append(fields, vdom.P(vdom.Attr("class", "text-xs text-red-600"), vdom.Text(msg)))
|
||||
fields = append(fields, vdom.P(vdom.Attr("class", "text-xs text-red-600 dark:text-red-400"), vdom.Text(msg)))
|
||||
}
|
||||
|
||||
saveLabel := "Add"
|
||||
@@ -3877,7 +3897,7 @@ func (s *AutoTableState) calcBasicEditor(e *calcEditor, summary bool) *vdom.VNod
|
||||
})))
|
||||
}
|
||||
|
||||
children = append(children, vdom.P(vdom.Attr("class", "text-xs text-neutral-500"),
|
||||
children = append(children, vdom.P(vdom.Attr("class", "text-xs text-ink-muted"),
|
||||
vdom.Text(calcBasicHint(e, summary))))
|
||||
|
||||
return vdom.Div(kids([]vdom.Mod{vdom.Attr("class", "flex flex-col gap-2")}, children)...)
|
||||
@@ -3908,12 +3928,12 @@ func (s *AutoTableState) calcAdvancedEditor(e *calcEditor, summary bool) *vdom.V
|
||||
label := o.Label
|
||||
colItems = append(colItems,
|
||||
e.colMenu.Item(MenuItemProps{OnClick: func() { e.insertAtCaret("[" + label + "]") }},
|
||||
vdom.Span(vdom.Attr("class", "font-mono text-sky-600"), vdom.Text("["+label+"]")),
|
||||
vdom.Span(vdom.Attr("class", "ml-2 text-xs text-neutral-500"), vdom.Text("this row")),
|
||||
vdom.Span(vdom.Attr("class", "font-mono text-sky-600 dark:text-sky-400"), vdom.Text("["+label+"]")),
|
||||
vdom.Span(vdom.Attr("class", "ml-2 text-xs text-ink-muted"), vdom.Text("this row")),
|
||||
),
|
||||
e.colMenu.Item(MenuItemProps{OnClick: func() { e.insertAtCaret("{" + label + "}") }},
|
||||
vdom.Span(vdom.Attr("class", "font-mono text-violet-600"), vdom.Text("{"+label+"}")),
|
||||
vdom.Span(vdom.Attr("class", "ml-2 text-xs text-neutral-500"), vdom.Text("whole column")),
|
||||
vdom.Span(vdom.Attr("class", "font-mono text-violet-600 dark:text-violet-400"), vdom.Text("{"+label+"}")),
|
||||
vdom.Span(vdom.Attr("class", "ml-2 text-xs text-ink-muted"), vdom.Text("whole column")),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -3925,7 +3945,7 @@ func (s *AutoTableState) calcAdvancedEditor(e *calcEditor, summary bool) *vdom.V
|
||||
groups := filterFormulaGroups(e.fnSearch.Get())
|
||||
if len(groups) == 0 {
|
||||
fnItems = append(fnItems, vdom.Div(
|
||||
vdom.Attr("class", "px-2 py-3 text-center text-xs text-neutral-500"),
|
||||
vdom.Attr("class", "px-2 py-3 text-center text-xs text-ink-muted"),
|
||||
vdom.Text("No functions match"),
|
||||
))
|
||||
}
|
||||
@@ -3940,7 +3960,7 @@ func (s *AutoTableState) calcAdvancedEditor(e *calcEditor, summary bool) *vdom.V
|
||||
// The signature is syntax-highlighted with the same highlighter the
|
||||
// formula box uses, so the menu and the editor speak one language.
|
||||
vdom.Span(vdom.Attr("class", "font-mono text-xs"), vdom.Raw(HighlightFormula(f.Sig))),
|
||||
vdom.Span(vdom.Attr("class", "text-xs text-neutral-500"), vdom.Text(f.Desc)),
|
||||
vdom.Span(vdom.Attr("class", "text-xs text-ink-muted"), vdom.Text(f.Desc)),
|
||||
),
|
||||
))
|
||||
}
|
||||
@@ -3953,8 +3973,8 @@ func (s *AutoTableState) calcAdvancedEditor(e *calcEditor, summary bool) *vdom.V
|
||||
OnClick: func() { e.insertAtCaret(cc.Name) },
|
||||
},
|
||||
vdom.Div(vdom.Attr("class", "flex flex-col items-start gap-0.5"),
|
||||
vdom.Span(vdom.Attr("class", "font-mono text-xs text-amber-600"), vdom.Text(cc.Name)),
|
||||
vdom.Span(vdom.Attr("class", "text-xs text-neutral-500"), vdom.Text(cc.Desc)),
|
||||
vdom.Span(vdom.Attr("class", "font-mono text-xs text-amber-600 dark:text-amber-400"), vdom.Text(cc.Name)),
|
||||
vdom.Span(vdom.Attr("class", "text-xs text-ink-muted"), vdom.Text(cc.Desc)),
|
||||
),
|
||||
))
|
||||
}
|
||||
@@ -4008,7 +4028,7 @@ func (s *AutoTableState) calcAdvancedEditor(e *calcEditor, summary bool) *vdom.V
|
||||
),
|
||||
)
|
||||
|
||||
help := vdom.P(vdom.Attr("class", "text-xs text-neutral-500"),
|
||||
help := vdom.P(vdom.Attr("class", "text-xs text-ink-muted"),
|
||||
vdom.Text("[Column] is this row's cell · {Column} is the whole column · {Column:1:ROW()} is a running total."))
|
||||
|
||||
children := []*vdom.VNode{menus, editor, help}
|
||||
@@ -4024,11 +4044,11 @@ func (s *AutoTableState) calcAdvancedEditor(e *calcEditor, summary bool) *vdom.V
|
||||
func (e *calcEditor) fnSearchBox() *vdom.VNode {
|
||||
wasmruntime.AfterRender(func() { wasmruntime.Focus(e.fnSearchRef) })
|
||||
|
||||
return vdom.Div(vdom.Attr("class", "sticky top-0 z-10 -m-1.5 mb-1 border-b border-neutral-200 bg-white p-1.5"),
|
||||
return vdom.Div(vdom.Attr("class", "sticky top-0 z-10 -m-1.5 mb-1 border-b border-line bg-surface p-1.5"),
|
||||
vdom.Input(
|
||||
vdom.WithRef(e.fnSearchRef),
|
||||
vdom.Attr("type", "text"),
|
||||
vdom.Attr("class", "w-full rounded-default border border-neutral-300 p-1 text-xs focus:outline-2 focus:outline-sky-500"),
|
||||
vdom.Attr("class", "w-full rounded-default border border-line-strong p-1 text-xs focus:outline-2 focus:outline-sky-500"),
|
||||
vdom.Attr("placeholder", "Search functions…"),
|
||||
vdom.Attr("spellcheck", "false"),
|
||||
vdom.Prop("value", e.fnSearch.Get()),
|
||||
@@ -4069,12 +4089,12 @@ func (s *AutoTableState) calcPreview(e *calcEditor, src string, summary bool) *v
|
||||
return nil
|
||||
}
|
||||
if _, err := CompileFormula(src); err != nil {
|
||||
return vdom.P(vdom.Attr("class", "text-xs text-red-600"), vdom.Text(err.Error()))
|
||||
return vdom.P(vdom.Attr("class", "text-xs text-red-600 dark:text-red-400"), vdom.Text(err.Error()))
|
||||
}
|
||||
|
||||
rows := s.FilteredRows()
|
||||
if len(rows) == 0 {
|
||||
return vdom.P(vdom.Attr("class", "text-xs text-neutral-500"), vdom.Text("Formula is valid."))
|
||||
return vdom.P(vdom.Attr("class", "text-xs text-ink-muted"), vdom.Text("Formula is valid."))
|
||||
}
|
||||
ctx := NewCalcContext(rows, s.cols, s.Calculated(), s.read)
|
||||
|
||||
@@ -4087,10 +4107,10 @@ func (s *AutoTableState) calcPreview(e *calcEditor, src string, summary bool) *v
|
||||
|
||||
n, err := EvalFormula(src, target)
|
||||
if err != nil {
|
||||
return vdom.P(vdom.Attr("class", "text-xs text-red-600"), vdom.Text(err.Error()))
|
||||
return vdom.P(vdom.Attr("class", "text-xs text-red-600 dark:text-red-400"), vdom.Text(err.Error()))
|
||||
}
|
||||
out := FormatCalcResult(n, CalculatedDataType(e.dataType.Get()), calcPrecisionOf(e.precision.Get()), "", "", "")
|
||||
return vdom.P(vdom.Attr("class", "text-xs text-neutral-500"), vdom.Text(prefix+out))
|
||||
return vdom.P(vdom.Attr("class", "text-xs text-ink-muted"), vdom.Text(prefix+out))
|
||||
}
|
||||
|
||||
// insertAtCaret drops text where the cursor is, rather than at the end — which is
|
||||
@@ -4121,7 +4141,7 @@ func (e *calcEditor) insertAtCaretOffset(text string, back int) {
|
||||
|
||||
func calcField(label string, children ...*vdom.VNode) *vdom.VNode {
|
||||
nodes := []*vdom.VNode{
|
||||
vdom.Span(vdom.Attr("class", "text-xs font-medium text-neutral-600"), vdom.Text(label)),
|
||||
vdom.Span(vdom.Attr("class", "text-xs font-medium text-ink-soft"), vdom.Text(label)),
|
||||
}
|
||||
nodes = append(nodes, children...)
|
||||
return vdom.Div(kids([]vdom.Mod{vdom.Attr("class", "flex flex-col gap-1")}, nodes)...)
|
||||
|
||||
@@ -362,17 +362,17 @@ func TestHighlightFormula(t *testing.T) {
|
||||
src string
|
||||
want []string // fragments that must appear
|
||||
}{
|
||||
{"cell ref", "[Revenue]", []string{`<span class="text-sky-600">[Revenue]</span>`}},
|
||||
{"column ref", "{Revenue}", []string{`<span class="text-violet-600">{Revenue}</span>`}},
|
||||
{"cell ref", "[Revenue]", []string{`<span class="` + fmlCellClass + `">[Revenue]</span>`}},
|
||||
{"column ref", "{Revenue}", []string{`<span class="` + fmlColumnClass + `">{Revenue}</span>`}},
|
||||
{"nested braces stay one span", "{Revenue:1:ROW()}",
|
||||
[]string{`<span class="text-violet-600">{Revenue:1:ROW()}</span>`}},
|
||||
[]string{`<span class="` + fmlColumnClass + `">{Revenue:1:ROW()}</span>`}},
|
||||
{"function", "SUM(",
|
||||
[]string{`<span class="text-emerald-700 font-semibold">SUM</span>`}},
|
||||
[]string{`<span class="` + fmlFuncClass + `">SUM</span>`}},
|
||||
{"function with a space before the paren", "SUM (",
|
||||
[]string{`<span class="text-emerald-700 font-semibold">SUM</span>`}},
|
||||
{"number", "12.5", []string{`<span class="text-amber-600">12.5</span>`}},
|
||||
{"constant", "PI", []string{`<span class="text-amber-600">PI</span>`}},
|
||||
{"operators", "1 + 2", []string{`<span class="text-neutral-400">+</span>`}},
|
||||
[]string{`<span class="` + fmlFuncClass + `">SUM</span>`}},
|
||||
{"number", "12.5", []string{`<span class="` + fmlNumberClass + `">12.5</span>`}},
|
||||
{"constant", "PI", []string{`<span class="` + fmlNumberClass + `">PI</span>`}},
|
||||
{"operators", "1 + 2", []string{`<span class="` + fmlOpClass + `">+</span>`}},
|
||||
{"a bare word is not coloured", "Revenue", []string{"Revenue"}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
@@ -455,7 +455,7 @@ func TestEditorOpensOnTheChooser(t *testing.T) {
|
||||
e.advanced.Set(true)
|
||||
e.formula.Set("[Gross revenue] * 12")
|
||||
adv := renderNode(s.calcForm(e, false))
|
||||
if !strings.Contains(adv, `<span class="text-sky-600">[Gross revenue]</span>`) {
|
||||
if !strings.Contains(adv, `<span class="`+fmlCellClass+`">[Gross revenue]</span>`) {
|
||||
t.Errorf("the formula is not syntax-highlighted:\n%s", adv)
|
||||
}
|
||||
for _, want := range []string{"Column", "Function", "Constant"} {
|
||||
|
||||
@@ -21,7 +21,7 @@ var badgeColors = map[string]string{
|
||||
"blue": "text-white bg-sky-800",
|
||||
"amber": "text-white bg-amber-700",
|
||||
"neutral": "text-white bg-neutral-500",
|
||||
"muted": "text-neutral-400 bg-transparent",
|
||||
"muted": "text-ink-faint bg-transparent",
|
||||
}
|
||||
|
||||
// BadgeProps configures Badge. When OnClick is set the badge renders as a
|
||||
|
||||
@@ -25,8 +25,8 @@ const btnBase = "inline-flex items-center justify-center gap-2 cursor-pointer te
|
||||
|
||||
var btnColors = map[string]string{
|
||||
"neutral": "shadow-xs bg-neutral-700 text-white hover:bg-neutral-800",
|
||||
"white": "shadow-xs bg-white text-black border border-neutral-300 hover:bg-neutral-50",
|
||||
"light-neutral": "shadow-xs bg-neutral-50 text-black border border-neutral-300 hover:bg-neutral-100",
|
||||
"white": "shadow-xs bg-surface text-ink border border-line-strong hover:bg-surface-muted",
|
||||
"light-neutral": "shadow-xs bg-surface-muted text-ink border border-line-strong hover:bg-surface-raised",
|
||||
"blue": "shadow-xs bg-sky-700 text-white hover:bg-sky-800",
|
||||
"dark-blue": "shadow-xs bg-sky-900 text-white hover:bg-sky-950",
|
||||
"green": "shadow-xs bg-green-700 text-white hover:bg-green-800",
|
||||
@@ -36,23 +36,23 @@ var btnColors = map[string]string{
|
||||
"yellow": "shadow-xs bg-yellow-700 text-white hover:bg-yellow-800",
|
||||
"orange": "shadow-xs bg-orange-600 text-white hover:bg-orange-700",
|
||||
"primary": "shadow-xs bg-primary text-white hover:bg-primary-hover",
|
||||
"secondary": "shadow-none bg-neutral-100 text-neutral-700 border border-neutral-300 hover:bg-neutral-200",
|
||||
"ghost": "shadow-none bg-transparent text-neutral-600 border-none hover:bg-neutral-100",
|
||||
"secondary": "shadow-none bg-surface-raised text-ink-soft border border-line-strong hover:bg-surface-strong",
|
||||
"ghost": "shadow-none bg-transparent text-ink-soft border-none hover:bg-surface-raised",
|
||||
}
|
||||
|
||||
var btnOutlineColors = map[string]string{
|
||||
"neutral": "text-neutral-700",
|
||||
"white": "text-neutral-300",
|
||||
"light-neutral": "text-neutral-300",
|
||||
"blue": "text-sky-700",
|
||||
"neutral": "text-ink-soft",
|
||||
"white": "text-ink-faint",
|
||||
"light-neutral": "text-ink-faint",
|
||||
"blue": "text-sky-700 dark:text-sky-400",
|
||||
"dark-blue": "text-sky-900",
|
||||
"green": "text-green-700",
|
||||
"green": "text-green-700 dark:text-green-400",
|
||||
"dark-green": "text-green-900",
|
||||
"red": "text-red-700",
|
||||
"dark-red": "text-red-900",
|
||||
"yellow": "text-yellow-700",
|
||||
"orange": "text-orange-600",
|
||||
"primary": "text-primary",
|
||||
"primary": "text-accent",
|
||||
}
|
||||
|
||||
const btnOutlineBase = "bg-transparent shadow-[inset_0_0_0_1px_currentColor] hover:shadow-[inset_0_0_0_2px_currentColor]"
|
||||
@@ -139,7 +139,7 @@ func iconSize(small bool) int {
|
||||
func ButtonLink(onClick func(), children ...*vdom.VNode) *vdom.VNode {
|
||||
mods := []vdom.Mod{
|
||||
vdom.Attr("type", "button"),
|
||||
vdom.Attr("class", "cursor-pointer bg-transparent border-none p-0 font-[inherit] text-sky-700 hover:underline"),
|
||||
vdom.Attr("class", "cursor-pointer bg-transparent border-none p-0 font-[inherit] text-sky-700 dark:text-sky-400 hover:underline"),
|
||||
}
|
||||
if onClick != nil {
|
||||
mods = append(mods, vdom.On(vdom.EVENT_CLICK, onClick))
|
||||
@@ -151,7 +151,7 @@ func ButtonLink(onClick func(), children ...*vdom.VNode) *vdom.VNode {
|
||||
func ButtonLinkRed(onClick func(), children ...*vdom.VNode) *vdom.VNode {
|
||||
mods := []vdom.Mod{
|
||||
vdom.Attr("type", "button"),
|
||||
vdom.Attr("class", "cursor-pointer bg-transparent border-none p-0 font-[inherit] text-red-600 hover:underline"),
|
||||
vdom.Attr("class", "cursor-pointer bg-transparent border-none p-0 font-[inherit] text-red-600 dark:text-red-400 hover:underline"),
|
||||
}
|
||||
if onClick != nil {
|
||||
mods = append(mods, vdom.On(vdom.EVENT_CLICK, onClick))
|
||||
@@ -174,10 +174,10 @@ func SegmentedButtons(options []SegmentedButtonOption, value string, onChange fu
|
||||
sizeCls = "py-0.5 px-2 text-xs"
|
||||
}
|
||||
const baseCls = "inline-flex items-center justify-center gap-1.5 flex-1 cursor-pointer font-medium transition-colors whitespace-nowrap disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
const activeCls = "bg-white text-text-heading shadow-sm"
|
||||
const inactiveCls = "text-neutral-500 hover:text-neutral-700"
|
||||
const activeCls = "bg-surface text-text-heading shadow-sm"
|
||||
const inactiveCls = "text-ink-muted hover:text-ink-soft"
|
||||
|
||||
mods := []vdom.Mod{vdom.Attr("class", cx("flex items-center gap-0.5 rounded-md bg-neutral-100 p-0.5", class))}
|
||||
mods := []vdom.Mod{vdom.Attr("class", cx("flex items-center gap-0.5 rounded-md bg-surface-raised p-0.5", class))}
|
||||
for _, opt := range options {
|
||||
state := inactiveCls
|
||||
if opt.Value == value {
|
||||
@@ -201,7 +201,7 @@ func SegmentedButtons(options []SegmentedButtonOption, value string, onChange fu
|
||||
|
||||
// BackLink is an inline chevron-left anchor. onDark switches to on-dark colors.
|
||||
func BackLink(href, text string, onDark bool) *vdom.VNode {
|
||||
color := "text-neutral-600 hover:text-neutral-900"
|
||||
color := "text-ink-soft hover:text-ink"
|
||||
if onDark {
|
||||
color = "text-text-on-dark-muted hover:text-text-on-dark"
|
||||
}
|
||||
|
||||
@@ -26,18 +26,18 @@ var calMonthsShort = []string{
|
||||
// exported from Calendar.tsx; here they stay unexported with a cal prefix so the
|
||||
// two ported files can share them without widening the package's public API. --
|
||||
const calPickerRoot = "p-2 min-w-[240px]"
|
||||
const calMonthRoot = "p-0 min-w-0 w-full bg-white border border-neutral-200 rounded-default shadow-sm overflow-hidden"
|
||||
const calMonthRoot = "p-0 min-w-0 w-full bg-surface border border-line rounded-default shadow-sm overflow-hidden"
|
||||
|
||||
const calHeaderPicker = "flex items-center justify-between mb-2 gap-1"
|
||||
const calHeaderMonth = "flex items-center justify-between gap-1 py-3 px-4 border-b border-neutral-200 bg-neutral-50"
|
||||
const calHeaderMonth = "flex items-center justify-between gap-1 py-3 px-4 border-b border-line bg-surface-muted"
|
||||
|
||||
const calNavBtn = "bg-transparent border-0 p-1 cursor-pointer text-text-muted rounded-sm flex items-center justify-center hover:bg-neutral-100 hover:text-text-body"
|
||||
const calNavBtn = "bg-transparent border-0 p-1 cursor-pointer text-text-muted rounded-sm flex items-center justify-center hover:bg-surface-raised hover:text-text-body"
|
||||
|
||||
const calMyPicker = "text-sm font-semibold text-text-heading mx-3 whitespace-nowrap"
|
||||
const calMyMonth = "text-lg font-heading mx-4 flex-1 text-center font-semibold text-text-heading whitespace-nowrap"
|
||||
|
||||
const calWeekdaysPicker = "grid grid-cols-7 gap-[2px] mb-1"
|
||||
const calWeekdaysMonth = "grid grid-cols-7 border-b border-neutral-200"
|
||||
const calWeekdaysMonth = "grid grid-cols-7 border-b border-line"
|
||||
const calWeekdayPicker = "text-center text-xs font-semibold text-text-muted p-1"
|
||||
const calWeekdayMonth = "text-center text-xs font-semibold text-text-muted p-2 uppercase tracking-wider"
|
||||
|
||||
@@ -45,9 +45,9 @@ const calDaysPicker = "grid grid-cols-7 gap-[2px]"
|
||||
const calDaysMonth = "grid grid-cols-7"
|
||||
|
||||
const calDayPickerBase = "aspect-square flex items-center justify-center text-sm bg-transparent border-0 rounded-sm cursor-pointer text-text-body p-0"
|
||||
const calDayMonthBase = "min-h-[6.5rem] flex flex-col items-stretch justify-start p-1.5 border-r border-b border-neutral-200 text-left gap-1 text-xs bg-transparent cursor-pointer"
|
||||
const calDayMonthBase = "min-h-[6.5rem] flex flex-col items-stretch justify-start p-1.5 border-r border-b border-line text-left gap-1 text-xs bg-transparent cursor-pointer"
|
||||
|
||||
const calSelect = "flex-1 py-1 px-2 text-sm font-semibold border border-neutral-200 rounded-sm bg-white text-text-heading cursor-pointer focus:outline-hidden focus:border-primary"
|
||||
const calSelect = "flex-1 py-1 px-2 text-sm font-semibold border border-line rounded-sm bg-surface text-text-heading cursor-pointer focus:outline-hidden focus:border-primary"
|
||||
|
||||
// Calendar variants.
|
||||
const (
|
||||
@@ -123,10 +123,10 @@ func calDayClassPicker(empty, selected, today bool) string {
|
||||
if empty {
|
||||
c = cx(c, "cursor-default")
|
||||
} else {
|
||||
c = cx(c, "hover:bg-neutral-100")
|
||||
c = cx(c, "hover:bg-surface-raised")
|
||||
}
|
||||
if today {
|
||||
c = cx(c, "font-bold text-primary")
|
||||
c = cx(c, "font-bold text-accent")
|
||||
}
|
||||
if selected {
|
||||
c = cx(c, "!bg-primary !text-white")
|
||||
@@ -142,9 +142,9 @@ func calDayClassMonth(idx int, empty, selected bool) string {
|
||||
c = cx(c, "border-r-0")
|
||||
}
|
||||
if empty {
|
||||
c = cx(c, "bg-neutral-50 cursor-default")
|
||||
c = cx(c, "bg-surface-muted cursor-default")
|
||||
} else {
|
||||
c = cx(c, "hover:bg-neutral-50")
|
||||
c = cx(c, "hover:bg-surface-muted")
|
||||
}
|
||||
if selected {
|
||||
c = cx(c, "bg-primary/10 text-text-body")
|
||||
|
||||
@@ -5,15 +5,15 @@ import "kjol/vdom"
|
||||
// Port of web/kit/Cards.tsx. The `ui-card`/`no-flex` marker classes are kept so
|
||||
// any page CSS targeting them still applies; baseline styling is Tailwind.
|
||||
|
||||
const cardBase = "ui-card bg-white shadow-sm rounded-default w-full"
|
||||
const cardBase = "ui-card bg-surface shadow-sm rounded-default w-full"
|
||||
const cardWithPadding = cardBase + " p-5 flex-1"
|
||||
const cardNoFlex = cardBase + " no-flex p-5"
|
||||
const cardNoPaddingNoFlex = cardBase + " no-padding no-flex"
|
||||
const borderCard = "border border-neutral-300 rounded-default p-5 w-full"
|
||||
const borderCard = "border border-line-strong rounded-default p-5 w-full"
|
||||
|
||||
// cutCornerCard uses two clip-path pseudo-elements to notch opposite corners.
|
||||
const cutCornerCard = "relative isolate p-5 w-full " +
|
||||
"before:content-[''] before:absolute before:inset-0 before:bg-neutral-300 before:-z-20 " +
|
||||
"before:content-[''] before:absolute before:inset-0 before:bg-surface-strong before:-z-20 " +
|
||||
"before:[clip-path:polygon(16px_0,100%_0,100%_calc(100%_-_16px),calc(100%_-_16px)_100%,0_100%,0_16px)] " +
|
||||
"after:content-[''] after:absolute after:inset-[1px] after:bg-white after:-z-10 " +
|
||||
"after:[clip-path:polygon(15px_0,100%_0,100%_calc(100%_-_15px),calc(100%_-_15px)_100%,0_100%,0_15px)]"
|
||||
@@ -47,7 +47,7 @@ func BorderCutCornerCard(class string, children ...*vdom.VNode) *vdom.VNode {
|
||||
return cardDiv(cutCornerCard, class, children)
|
||||
}
|
||||
|
||||
const cardHeader = "text-xl tracking-tight text-black mb-5"
|
||||
const cardHeader = "text-xl tracking-tight text-ink mb-5"
|
||||
const cardHeaderHR = "text-neutral-200 mt-1 mb-3"
|
||||
|
||||
// CardHeader renders a card title followed by a divider.
|
||||
@@ -66,7 +66,7 @@ func CardHeaderTextCenter(class string, children ...*vdom.VNode) *vdom.VNode {
|
||||
|
||||
// CardSubheader renders a smaller secondary heading.
|
||||
func CardSubheader(class string, children ...*vdom.VNode) *vdom.VNode {
|
||||
return vdom.Div(kids([]vdom.Mod{vdom.Attr("class", cx("text-lg tracking-tight text-black mb-2", class))}, children)...)
|
||||
return vdom.Div(kids([]vdom.Mod{vdom.Attr("class", cx("text-lg tracking-tight text-ink mb-2", class))}, children)...)
|
||||
}
|
||||
|
||||
// CardSpacer is vertical spacing between cards.
|
||||
|
||||
@@ -35,7 +35,7 @@ import (
|
||||
|
||||
// GridHeaderCls is the base <th> class for CellGrid headers (exported, matching
|
||||
// the TSX GRID_HEADER_CLS).
|
||||
const GridHeaderCls = "border-b border-r border-neutral-300 bg-neutral-50 px-1.5 py-1.5 text-left text-xs font-bold uppercase text-black whitespace-nowrap last:border-r-0"
|
||||
const GridHeaderCls = "border-b border-r border-line-strong bg-surface-muted px-1.5 py-1.5 text-left text-xs font-bold uppercase text-ink whitespace-nowrap last:border-r-0"
|
||||
|
||||
var cgLeadingDigits = regexp.MustCompile(`^\d+`)
|
||||
var cgLeadingFloat = regexp.MustCompile(`^[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?`)
|
||||
@@ -185,7 +185,7 @@ type SortableHeaderProps struct {
|
||||
// column and calls OnSort(SortKey) on click.
|
||||
func SortableHeader(p SortableHeaderProps) *vdom.VNode {
|
||||
isActive := p.SortKey != "" && p.Current == p.SortKey
|
||||
cls := GridHeaderCls + " cursor-pointer select-none hover:bg-neutral-200"
|
||||
cls := GridHeaderCls + " cursor-pointer select-none hover:bg-surface-strong"
|
||||
if sz := cellGridColumnSize(p.Width, p.MinWidth); sz != "" {
|
||||
cls += " " + sz
|
||||
}
|
||||
@@ -311,9 +311,9 @@ func CellGrid(p CellGridProps) *vdom.VNode {
|
||||
if p.Dense {
|
||||
rowHCls = "h-6"
|
||||
}
|
||||
readonlyTdCls := "border-b border-r border-neutral-300 bg-black/5 px-2 text-neutral-700 align-middle " + rowHCls
|
||||
const editableTdCls = "border-b border-r border-neutral-300 p-0 relative align-middle"
|
||||
const inputCls = "absolute inset-0 w-full border-none outline-none bg-transparent px-2 placeholder:text-neutral-400 focus:bg-red-50 focus:shadow-[inset_0_0_0_2px_var(--color-red-500)]"
|
||||
readonlyTdCls := "border-b border-r border-line-strong bg-black/5 px-2 text-ink-soft align-middle " + rowHCls
|
||||
const editableTdCls = "border-b border-r border-line-strong p-0 relative align-middle"
|
||||
const inputCls = "absolute inset-0 w-full border-none outline-none bg-transparent px-2 placeholder:text-ink-faint focus:bg-red-50 dark:bg-red-950 focus:shadow-[inset_0_0_0_2px_var(--color-red-500)]"
|
||||
|
||||
conflicts := cellGridConflictSets(p)
|
||||
isConflict := func(field string, value any) bool {
|
||||
@@ -417,7 +417,7 @@ func CellGrid(p CellGridProps) *vdom.VNode {
|
||||
if inList {
|
||||
tdCls += " relative"
|
||||
if hasConflict {
|
||||
tdCls += " bg-amber-100"
|
||||
tdCls += " bg-amber-100 dark:bg-amber-900"
|
||||
}
|
||||
}
|
||||
im := col.InputMode
|
||||
@@ -441,7 +441,7 @@ func CellGrid(p CellGridProps) *vdom.VNode {
|
||||
)
|
||||
mods := []vdom.Mod{vdom.Attr("class", tdCls), input}
|
||||
if inList && hasConflict {
|
||||
mods = append(mods, vdom.Span(vdom.Attr("class", "pointer-events-none absolute right-0.5 top-1/2 -translate-y-1/2 text-amber-600"),
|
||||
mods = append(mods, vdom.Span(vdom.Attr("class", "pointer-events-none absolute right-0.5 top-1/2 -translate-y-1/2 text-amber-600 dark:text-amber-400"),
|
||||
vdom.Attr("title", "Duplicate value"),
|
||||
Icon("triangle-exclamation", 12, ""),
|
||||
))
|
||||
@@ -461,7 +461,7 @@ func CellGrid(p CellGridProps) *vdom.VNode {
|
||||
for _, col := range p.Columns {
|
||||
cells = append(cells, renderCell(row, col))
|
||||
}
|
||||
bodyRows = append(bodyRows, vdom.Tr(kids([]vdom.Mod{vdom.Attr("class", "odd:bg-white even:bg-neutral-100")}, cells)...))
|
||||
bodyRows = append(bodyRows, vdom.Tr(kids([]vdom.Mod{vdom.Attr("class", "odd:bg-surface even:bg-surface-raised")}, cells)...))
|
||||
}
|
||||
tbody := vdom.Tbody(kids(nil, bodyRows)...)
|
||||
|
||||
@@ -469,7 +469,7 @@ func CellGrid(p CellGridProps) *vdom.VNode {
|
||||
if p.Dense {
|
||||
tableCls = "min-w-full w-max border-collapse text-xs"
|
||||
}
|
||||
return vdom.Div(vdom.Attr("class", "relative max-w-full overflow-x-auto border border-neutral-300 rounded-default bg-white tabular-nums"),
|
||||
return vdom.Div(vdom.Attr("class", "relative max-w-full overflow-x-auto border border-line-strong rounded-default bg-surface tabular-nums"),
|
||||
vdom.Table(vdom.Attr("class", tableCls), thead, tbody),
|
||||
)
|
||||
}
|
||||
|
||||
174
go/webui/code.go
Normal file
174
go/webui/code.go
Normal file
@@ -0,0 +1,174 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"html"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Go syntax highlighting, for the code samples a documentation page shows.
|
||||
//
|
||||
// It is a LEXER, not a parser: it classifies tokens and gives up gracefully on anything
|
||||
// it does not understand, because a highlighter that can fail to render is worse than
|
||||
// one that occasionally paints an identifier the wrong colour. Unterminated strings and
|
||||
// comments run to the end of the input rather than throwing.
|
||||
//
|
||||
// Output is HTML, and every run of source text passes through html.EscapeString on the
|
||||
// way out — the input is Go source, which is full of `<`, `>` and `&`, and one of the
|
||||
// snippets this is meant to display is literally a block of HTML.
|
||||
|
||||
// A code block is dark in BOTH themes — a light code block on a light page is a
|
||||
// different kind of thing, and switching it with the theme means the snippet you were
|
||||
// reading changes colour under you. So these are fixed on-dark colours, not theme
|
||||
// tokens: the surface they sit on never changes.
|
||||
const (
|
||||
goCommentClass = "text-neutral-400"
|
||||
goStringClass = "text-emerald-300"
|
||||
goKeywordClass = "text-sky-300"
|
||||
goNumberClass = "text-amber-300"
|
||||
goFuncClass = "text-violet-300"
|
||||
)
|
||||
|
||||
var goKeywords = map[string]bool{
|
||||
"break": true, "case": true, "chan": true, "const": true, "continue": true,
|
||||
"default": true, "defer": true, "else": true, "fallthrough": true, "for": true,
|
||||
"func": true, "go": true, "goto": true, "if": true, "import": true,
|
||||
"interface": true, "map": true, "package": true, "range": true, "return": true,
|
||||
"select": true, "struct": true, "switch": true, "type": true, "var": true,
|
||||
// Not keywords to the Go spec — predeclared identifiers — but every editor colours
|
||||
// them, and a reader looking for `nil` is looking for the same kind of thing.
|
||||
"nil": true, "true": true, "false": true, "iota": true,
|
||||
"string": true, "int": true, "int64": true, "float64": true, "bool": true,
|
||||
"byte": true, "rune": true, "any": true, "error": true,
|
||||
}
|
||||
|
||||
// HighlightGo turns Go source into HTML with the tokens wrapped in coloured spans.
|
||||
//
|
||||
// The result is meant for vdom.Raw inside a <pre>: it contains no block elements and
|
||||
// preserves the source's whitespace exactly, so the <pre> does the layout.
|
||||
func HighlightGo(src string) string {
|
||||
var b strings.Builder
|
||||
b.Grow(len(src) * 2)
|
||||
|
||||
i := 0
|
||||
for i < len(src) {
|
||||
c := src[i]
|
||||
|
||||
switch {
|
||||
// Line comment — including the //gowasm: directives, which are the most
|
||||
// important line in several of these snippets.
|
||||
case c == '/' && i+1 < len(src) && src[i+1] == '/':
|
||||
end := strings.IndexByte(src[i:], '\n')
|
||||
if end < 0 {
|
||||
end = len(src)
|
||||
} else {
|
||||
end += i
|
||||
}
|
||||
span(&b, goCommentClass, src[i:end])
|
||||
i = end
|
||||
|
||||
// Block comment.
|
||||
case c == '/' && i+1 < len(src) && src[i+1] == '*':
|
||||
end := strings.Index(src[i+2:], "*/")
|
||||
if end < 0 {
|
||||
end = len(src)
|
||||
} else {
|
||||
end = i + 2 + end + 2
|
||||
}
|
||||
span(&b, goCommentClass, src[i:end])
|
||||
i = end
|
||||
|
||||
// Interpreted string. Ends at the closing quote or the line's end — an
|
||||
// unterminated string is a typo in a snippet, not a reason to paint the rest of
|
||||
// the file green.
|
||||
case c == '"':
|
||||
i = quoted(&b, src, i, '"', true)
|
||||
|
||||
// Raw string: no escapes, and it may span lines.
|
||||
case c == '`':
|
||||
i = quoted(&b, src, i, '`', false)
|
||||
|
||||
// Rune literal.
|
||||
case c == '\'':
|
||||
i = quoted(&b, src, i, '\'', true)
|
||||
|
||||
case isDigit(c):
|
||||
j := i
|
||||
for j < len(src) && (isDigit(src[j]) || isHexish(src[j])) {
|
||||
j++
|
||||
}
|
||||
span(&b, goNumberClass, src[i:j])
|
||||
i = j
|
||||
|
||||
case isIdentStart(c):
|
||||
j := i
|
||||
for j < len(src) && isIdentPart(src[j]) {
|
||||
j++
|
||||
}
|
||||
word := src[i:j]
|
||||
switch {
|
||||
case goKeywords[word]:
|
||||
span(&b, goKeywordClass, word)
|
||||
case callAhead(src, j):
|
||||
// An identifier immediately followed by "(" is being called (or is a type
|
||||
// being converted to). Colouring it is what makes the shape of a snippet
|
||||
// readable at a glance.
|
||||
span(&b, goFuncClass, word)
|
||||
default:
|
||||
b.WriteString(html.EscapeString(word))
|
||||
}
|
||||
i = j
|
||||
|
||||
default:
|
||||
b.WriteString(html.EscapeString(string(c)))
|
||||
i++
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// quoted consumes a quoted literal starting at i and writes it as a string span.
|
||||
// escapes reports whether a backslash escapes the next byte (false for raw strings).
|
||||
func quoted(b *strings.Builder, src string, i int, quote byte, escapes bool) int {
|
||||
j := i + 1
|
||||
for j < len(src) {
|
||||
if escapes && src[j] == '\\' && j+1 < len(src) {
|
||||
j += 2
|
||||
continue
|
||||
}
|
||||
if src[j] == quote {
|
||||
j++
|
||||
break
|
||||
}
|
||||
if escapes && src[j] == '\n' {
|
||||
break // unterminated: stop at the line end rather than eating the file
|
||||
}
|
||||
j++
|
||||
}
|
||||
span(b, goStringClass, src[i:j])
|
||||
return j
|
||||
}
|
||||
|
||||
// callAhead reports whether the next non-space byte at or after i is an opening paren.
|
||||
func callAhead(src string, i int) bool {
|
||||
for i < len(src) && (src[i] == ' ' || src[i] == '\t') {
|
||||
i++
|
||||
}
|
||||
return i < len(src) && src[i] == '('
|
||||
}
|
||||
|
||||
func span(b *strings.Builder, class, text string) {
|
||||
b.WriteString(`<span class="`)
|
||||
b.WriteString(class)
|
||||
b.WriteString(`">`)
|
||||
b.WriteString(html.EscapeString(text))
|
||||
b.WriteString(`</span>`)
|
||||
}
|
||||
|
||||
// isDigit already exists in the package (autotable.go) — reused rather than shadowed.
|
||||
|
||||
func isHexish(c byte) bool {
|
||||
return c == '.' || c == 'x' || c == 'X' || c == '_' ||
|
||||
(c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')
|
||||
}
|
||||
func isIdentStart(c byte) bool { return c == '_' || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') }
|
||||
func isIdentPart(c byte) bool { return isIdentStart(c) || isDigit(c) }
|
||||
99
go/webui/code_test.go
Normal file
99
go/webui/code_test.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The highlighter emits HTML, and its input is Go source — which is full of <, > and &.
|
||||
// Anything that reaches the page unescaped is markup injection into your own docs page:
|
||||
// a snippet containing `<div>` would render a div.
|
||||
func TestHighlightGoEscapes(t *testing.T) {
|
||||
got := HighlightGo(`s := "<div class=\"x\">" // a & b`)
|
||||
|
||||
if strings.Contains(got, "<div") {
|
||||
t.Errorf("a `<div>` in the SOURCE reached the output as markup:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "<div") {
|
||||
t.Errorf("the angle bracket was not escaped:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "&") {
|
||||
t.Errorf("the ampersand was not escaped:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHighlightGoClassifies(t *testing.T) {
|
||||
got := HighlightGo("func main() { x := 42 // note\n}")
|
||||
|
||||
for _, want := range []struct{ what, class, text string }{
|
||||
{"keyword", goKeywordClass, "func"},
|
||||
{"call", goFuncClass, "main"},
|
||||
{"number", goNumberClass, "42"},
|
||||
{"comment", goCommentClass, "// note"},
|
||||
} {
|
||||
if !strings.Contains(got, `<span class="`+want.class+`">`+want.text+`</span>`) {
|
||||
t.Errorf("%s %q was not highlighted:\n%s", want.what, want.text, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The //gowasm: directives are the most important line in half these snippets. They are
|
||||
// comments, and must survive as such.
|
||||
func TestHighlightGoKeepsDirectives(t *testing.T) {
|
||||
got := HighlightGo("//gowasm:page / static layout=public\nfunc HomePage() {}")
|
||||
if !strings.Contains(got, `<span class="`+goCommentClass+`">//gowasm:page / static layout=public</span>`) {
|
||||
t.Errorf("the directive was not kept whole as a comment:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A lexer that can hang or eat the rest of the file on malformed input would take the
|
||||
// whole page down with it. Unterminated literals stop; they do not run away.
|
||||
func TestHighlightGoSurvivesMalformedInput(t *testing.T) {
|
||||
for _, src := range []string{
|
||||
`x := "unterminated`,
|
||||
"y := `unterminated raw",
|
||||
"/* unterminated block",
|
||||
`z := '`,
|
||||
"",
|
||||
} {
|
||||
got := HighlightGo(src)
|
||||
// The text must all still be there — mangling is not an acceptable failure mode
|
||||
// either. Compare on the visible characters, ignoring the spans.
|
||||
if plain := stripTags(got); plain != src {
|
||||
t.Errorf("input %q came out as %q", src, plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing is dropped: every byte of the source is still on the page, in order.
|
||||
func TestHighlightGoIsLossless(t *testing.T) {
|
||||
src := "package app\n\nimport \"strings\"\n\nfunc f(n int) string {\n\treturn strings.Repeat(\"x\", n) // pad\n}\n"
|
||||
if plain := stripTags(HighlightGo(src)); plain != src {
|
||||
t.Errorf("the highlighter changed the source.\n got: %q\nwant: %q", plain, src)
|
||||
}
|
||||
}
|
||||
|
||||
// stripTags removes the spans and unescapes, recovering the original source.
|
||||
func stripTags(s string) string {
|
||||
var b strings.Builder
|
||||
for i := 0; i < len(s); {
|
||||
if s[i] == '<' {
|
||||
j := strings.IndexByte(s[i:], '>')
|
||||
if j < 0 {
|
||||
break
|
||||
}
|
||||
i += j + 1
|
||||
continue
|
||||
}
|
||||
b.WriteByte(s[i])
|
||||
i++
|
||||
}
|
||||
out := b.String()
|
||||
// Reverse html.EscapeString, innermost last.
|
||||
out = strings.ReplaceAll(out, "<", "<")
|
||||
out = strings.ReplaceAll(out, ">", ">")
|
||||
out = strings.ReplaceAll(out, """, `"`)
|
||||
out = strings.ReplaceAll(out, "'", "'")
|
||||
out = strings.ReplaceAll(out, "&", "&")
|
||||
return out
|
||||
}
|
||||
@@ -49,9 +49,9 @@ func crmTabsPanels(p CrmTabGroupProps) []*vdom.VNode {
|
||||
// --- CrmTabGroup: boxed top-accent tabs -------------------------------------
|
||||
|
||||
const crmTabRow = "flex w-full overflow-x-auto text-sm"
|
||||
const crmTabBase = "flex items-center gap-1.5 cursor-pointer p-4 font-medium border-neutral-300 transition-colors"
|
||||
const crmTabInactive = "border-b text-neutral-500 hover:text-neutral-800"
|
||||
const crmTabActive = "border-x border-t-2 border-t-sky-700 text-primary"
|
||||
const crmTabBase = "flex items-center gap-1.5 cursor-pointer p-4 font-medium border-line-strong transition-colors"
|
||||
const crmTabInactive = "border-b text-ink-muted hover:text-ink"
|
||||
const crmTabActive = "border-x border-t-2 border-t-sky-700 text-accent"
|
||||
const crmTabBadge = "inline-flex items-center justify-center min-w-5 h-5 px-1 text-xs font-semibold bg-primary text-white rounded-full"
|
||||
|
||||
// CrmTabGroup renders boxed tabs with a sky-blue top accent; content sits flat
|
||||
@@ -79,7 +79,7 @@ func CrmTabGroup(p CrmTabGroupProps) *vdom.VNode {
|
||||
}
|
||||
row = append(row, vdom.Button(btn...))
|
||||
}
|
||||
row = append(row, vdom.Div(vdom.Attr("class", "flex-1 border-b border-neutral-300")))
|
||||
row = append(row, vdom.Div(vdom.Attr("class", "flex-1 border-b border-line-strong")))
|
||||
|
||||
return vdom.Div(vdom.Attr("class", "w-full"),
|
||||
vdom.Div(row...),
|
||||
@@ -89,12 +89,12 @@ func CrmTabGroup(p CrmTabGroupProps) *vdom.VNode {
|
||||
|
||||
// --- CrmSubTabGroup: segmented control --------------------------------------
|
||||
|
||||
const crmSubTabWrap = "flex pb-3 border-b border-neutral-300 overflow-x-auto"
|
||||
const crmSubTabGroup = "inline-flex items-stretch rounded-md border border-neutral-300 overflow-hidden text-sm select-none"
|
||||
const crmSubTabWrap = "flex pb-3 border-b border-line-strong overflow-x-auto"
|
||||
const crmSubTabGroup = "inline-flex items-stretch rounded-md border border-line-strong overflow-hidden text-sm select-none"
|
||||
const crmSubTabBase = "flex items-center gap-1.5 py-1 px-3 cursor-pointer font-medium whitespace-nowrap transition-colors"
|
||||
const crmSubTabDivider = "border-l border-neutral-300"
|
||||
const crmSubTabDivider = "border-l border-line-strong"
|
||||
const crmSubTabActive = "bg-neutral-500 text-white"
|
||||
const crmSubTabInactive = "bg-white text-neutral-600 hover:bg-neutral-100 hover:text-neutral-900"
|
||||
const crmSubTabInactive = "bg-surface text-ink-soft hover:bg-surface-raised hover:text-ink"
|
||||
const crmSubTabBadge = "inline-flex items-center justify-center min-w-5 h-5 px-1 text-xs font-semibold bg-black/10 text-current rounded-full"
|
||||
|
||||
// CrmSubTabGroup renders a left-aligned segmented control (interlocking
|
||||
|
||||
@@ -30,7 +30,7 @@ import (
|
||||
|
||||
const datePickerWrap = "relative w-full min-w-0"
|
||||
const datePickerField = "relative w-full min-w-0 cursor-pointer [&_.ui-form]:m-0 [&_input]:cursor-text"
|
||||
const datePickerDropdown = "bg-white border border-neutral-200 rounded-default shadow-[0_4px_12px_rgba(0,0,0,0.15)] p-1 min-w-[16rem]"
|
||||
const datePickerDropdown = "bg-surface border border-line rounded-default shadow-[0_4px_12px_rgba(0,0,0,0.15)] p-1 min-w-[16rem]"
|
||||
const datePickerIconBtn = "absolute inset-y-0 right-0 z-[1] flex items-center justify-center bg-transparent border-0 px-2 cursor-pointer text-text-muted leading-none hover:text-text-body pointer-events-auto"
|
||||
const datePickerClearBtn = "absolute inset-y-0 right-8 z-[1] flex items-center justify-center bg-transparent border-0 px-1.5 cursor-pointer text-text-muted leading-none hover:text-text-body pointer-events-auto"
|
||||
|
||||
|
||||
@@ -403,6 +403,13 @@ type FloatingTriggerProps struct {
|
||||
// OnClick runs in addition to the toggle (which is suppressed when the trigger
|
||||
// opens on hover).
|
||||
OnClick func()
|
||||
|
||||
// NoToggle anchors the panel to this element without making it a switch.
|
||||
//
|
||||
// A text field is not a switch: the panel opens because you typed, and closes
|
||||
// because you chose something. Toggling on click means clicking back into the field
|
||||
// to fix a typo dismisses the results you were reading.
|
||||
NoToggle bool
|
||||
}
|
||||
|
||||
// Trigger renders the element the panel is anchored to.
|
||||
@@ -436,6 +443,10 @@ func (f *Floating) Trigger(p FloatingTriggerProps, children ...*vdom.VNode) *vdo
|
||||
if p.OnClick != nil {
|
||||
mods = append(mods, vdom.On(vdom.EVENT_CLICK, p.OnClick))
|
||||
}
|
||||
} else if p.NoToggle {
|
||||
if p.OnClick != nil {
|
||||
mods = append(mods, vdom.On(vdom.EVENT_CLICK, p.OnClick))
|
||||
}
|
||||
} else {
|
||||
mods = append(mods, vdom.On(vdom.EVENT_CLICK, func() {
|
||||
if p.OnClick != nil {
|
||||
|
||||
@@ -23,28 +23,28 @@ import (
|
||||
|
||||
// -- shared Tailwind class strings (verbatim from Forms.tsx) -------------------
|
||||
|
||||
const formInputBase = "bg-white block w-full border rounded-default shadow-xs text-sm focus:outline-2 focus:outline-offset-1 disabled:bg-neutral-100 disabled:cursor-not-allowed"
|
||||
const formInputBase = "bg-surface block w-full border rounded-default shadow-xs text-sm focus:outline-2 focus:outline-offset-1 disabled:bg-surface-raised disabled:cursor-not-allowed"
|
||||
const formInputBaseDark = "bg-dark text-text-on-dark placeholder:text-text-on-dark-faint block w-full border rounded-default shadow-xs text-sm focus:outline-2 focus:outline-offset-1 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
|
||||
const formErrorCls = "block text-red-600 text-xs mt-1"
|
||||
const formErrorCls = "block text-red-600 dark:text-red-400 text-xs mt-1"
|
||||
const formSuccessCls = "block text-green-600 text-xs mt-1"
|
||||
const formInputGroupCls = "flex flex-row items-stretch w-full text-sm"
|
||||
|
||||
const formTriggerBase = "bg-white border border-neutral-300 rounded-default shadow-xs text-sm w-full text-left flex items-center justify-between gap-2 cursor-pointer disabled:bg-neutral-100 disabled:cursor-not-allowed"
|
||||
const formTriggerBase = "bg-surface border border-line-strong rounded-default shadow-xs text-sm w-full text-left flex items-center justify-between gap-2 cursor-pointer disabled:bg-surface-raised disabled:cursor-not-allowed"
|
||||
const formTriggerBaseDark = "bg-dark-raised border border-border-on-dark text-text-on-dark rounded-default shadow-xs text-sm w-full text-left flex items-center justify-between gap-2 cursor-pointer hover:border-border-on-dark-hover disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
|
||||
const formDropdown = "bg-white border border-neutral-300 rounded-default shadow-lg max-h-60 overflow-auto"
|
||||
const formDropdown = "bg-surface border border-line-strong rounded-default shadow-lg max-h-60 overflow-auto"
|
||||
const formDropdownDark = "bg-dark-raised border border-border-on-dark rounded-default shadow-lg max-h-60 overflow-auto"
|
||||
const formDropdownSearchWrap = "sticky top-0 bg-white border-b border-neutral-200 p-2"
|
||||
const formDropdownSearchWrap = "sticky top-0 bg-surface border-b border-line p-2"
|
||||
const formDropdownSearchWrapDark = "sticky top-0 bg-dark-raised border-b border-border-on-dark p-2"
|
||||
const formDropdownSearchInput = "w-full bg-white border border-neutral-300 rounded-default shadow-xs text-sm p-1 focus:outline-2 focus:outline-sky-500 focus:outline-offset-1"
|
||||
const formDropdownSearchInput = "w-full bg-surface border border-line-strong rounded-default shadow-xs text-sm p-1 focus:outline-2 focus:outline-sky-500 focus:outline-offset-1"
|
||||
const formDropdownSearchInputDark = "w-full bg-dark border border-border-on-dark text-text-on-dark placeholder:text-text-on-dark-faint rounded-default shadow-xs text-sm p-1 focus:outline-2 focus:outline-sky-500 focus:outline-offset-1"
|
||||
const formDropdownOption = "w-full text-left p-2 text-sm cursor-pointer flex items-center gap-2 bg-transparent border-none hover:bg-neutral-100 disabled:text-neutral-400 disabled:cursor-not-allowed whitespace-nowrap"
|
||||
const formDropdownOption = "w-full text-left p-2 text-sm cursor-pointer flex items-center gap-2 bg-transparent border-none hover:bg-surface-raised disabled:text-ink-faint disabled:cursor-not-allowed whitespace-nowrap"
|
||||
const formDropdownOptionDark = "w-full text-left p-2 text-sm cursor-pointer flex items-center gap-2 bg-transparent border-none text-text-on-dark hover:bg-white/5 disabled:text-text-on-dark-faint disabled:cursor-not-allowed whitespace-nowrap"
|
||||
const formDropdownNoResults = "p-2 text-sm text-neutral-500 text-center"
|
||||
const formDropdownNoResults = "p-2 text-sm text-ink-muted text-center"
|
||||
const formDropdownNoResultsDark = "p-2 text-sm text-text-on-dark-muted text-center"
|
||||
const formSelectAllWrap = "border-b border-neutral-200"
|
||||
const formSelectAllBtn = "w-full text-left p-2 text-sm cursor-pointer text-neutral-600 font-medium bg-transparent border-none hover:bg-neutral-100"
|
||||
const formSelectAllWrap = "border-b border-line"
|
||||
const formSelectAllBtn = "w-full text-left p-2 text-sm cursor-pointer text-ink-soft font-medium bg-transparent border-none hover:bg-surface-raised"
|
||||
|
||||
// -- shared class builders -----------------------------------------------------
|
||||
|
||||
@@ -59,7 +59,7 @@ func formControlH(small bool) string {
|
||||
// formFieldBorder picks the border/focus-outline color: error > success > normal.
|
||||
// error/success are the message strings; non-empty means "present" (truthy).
|
||||
func formFieldBorder(errMsg, successMsg string, onDark bool) string {
|
||||
borderNormal := "border-neutral-300 focus:outline-sky-500"
|
||||
borderNormal := "border-line-strong focus:outline-sky-500"
|
||||
if onDark {
|
||||
borderNormal = "border-border-on-dark focus:outline-sky-500"
|
||||
}
|
||||
@@ -100,8 +100,8 @@ func formTextareaCls(small bool, errMsg, extra string, onDark bool) string {
|
||||
}
|
||||
|
||||
func formPrefixCls(small bool, errMsg string, onDark bool) string {
|
||||
base := "bg-neutral-100 border shadow-xs border-r-0 flex items-center shrink-0 rounded-l-default"
|
||||
borderNormal := "border-neutral-300"
|
||||
base := "bg-surface-raised border shadow-xs border-r-0 flex items-center shrink-0 rounded-l-default"
|
||||
borderNormal := "border-line-strong"
|
||||
if onDark {
|
||||
base = "bg-dark-raised text-text-on-dark-muted border shadow-xs border-r-0 flex items-center shrink-0 rounded-l-default"
|
||||
borderNormal = "border-border-on-dark"
|
||||
@@ -662,7 +662,7 @@ func FormLabel(p FormLabelProps, children ...*vdom.VNode) *vdom.VNode {
|
||||
if p.Inline {
|
||||
display = "inline"
|
||||
}
|
||||
color := "text-neutral-700"
|
||||
color := "text-ink-soft"
|
||||
if p.OnDark {
|
||||
color = "text-text-on-dark"
|
||||
}
|
||||
@@ -685,8 +685,8 @@ func FormFileInput(p FormInputProps) *vdom.VNode {
|
||||
filePad = "file:py-[2px] file:px-3"
|
||||
}
|
||||
class := cx(formInputBase,
|
||||
"p-1 border-neutral-300 focus:outline-sky-500 cursor-pointer",
|
||||
"file:ml-1 file:mr-2 file:bg-neutral-100 file:border file:border-neutral-300 file:rounded-default file:shadow-xs file:text-sm file:cursor-pointer file:hover:bg-neutral-200",
|
||||
"p-1 border-line-strong focus:outline-sky-500 cursor-pointer",
|
||||
"file:ml-1 file:mr-2 file:bg-surface-raised file:border file:border-line-strong file:rounded-default file:shadow-xs file:text-sm file:cursor-pointer file:hover:bg-surface-strong",
|
||||
filePad, p.Class)
|
||||
mods := []vdom.Mod{
|
||||
vdom.Attr("type", "file"),
|
||||
@@ -717,8 +717,8 @@ func FormSpacer() *vdom.VNode { return vdom.Div(vdom.Attr("class", "mb-3")) }
|
||||
// FormFieldset wraps children in a bordered <fieldset> with an optional legend.
|
||||
func FormFieldset(legend, class string, children ...*vdom.VNode) *vdom.VNode {
|
||||
mods := []vdom.Mod{
|
||||
vdom.Attr("class", cx("border border-neutral-300 rounded-default py-3 px-4", class)),
|
||||
vdom.Legend(vdom.Attr("class", "px-2 text-sm font-medium text-neutral-600"), vdom.Text(legend)),
|
||||
vdom.Attr("class", cx("border border-line-strong rounded-default py-3 px-4", class)),
|
||||
vdom.Legend(vdom.Attr("class", "px-2 text-sm font-medium text-ink-soft"), vdom.Text(legend)),
|
||||
}
|
||||
mods = kids(mods, children)
|
||||
return vdom.Fieldset(mods...)
|
||||
@@ -954,7 +954,7 @@ func (d *dropdown) optionButton(opt FormSelectOption, idx int, selected, onDark,
|
||||
cls = cx(cls, "py-1.5 px-2")
|
||||
}
|
||||
if idx == d.active.Get() {
|
||||
cls = cx(cls, "bg-neutral-100")
|
||||
cls = cx(cls, "bg-surface-raised")
|
||||
}
|
||||
|
||||
mods := []vdom.Mod{
|
||||
@@ -1073,7 +1073,7 @@ func (c *Combobox) Render(p FormComboboxProps) *vdom.VNode {
|
||||
}
|
||||
|
||||
display := pick(p.Placeholder, "Select an option")
|
||||
placeholderCls := "text-neutral-500"
|
||||
placeholderCls := "text-ink-muted"
|
||||
if p.OnDark {
|
||||
placeholderCls = "text-text-on-dark-muted"
|
||||
}
|
||||
@@ -1082,7 +1082,7 @@ func (c *Combobox) Render(p FormComboboxProps) *vdom.VNode {
|
||||
placeholderCls = ""
|
||||
}
|
||||
|
||||
chevronCls := "text-neutral-400"
|
||||
chevronCls := "text-ink-faint"
|
||||
if p.OnDark {
|
||||
chevronCls = "text-text-on-dark-muted"
|
||||
}
|
||||
@@ -1178,6 +1178,12 @@ type FormMultiSelectProps struct {
|
||||
Small bool
|
||||
Class string
|
||||
FieldWidth string
|
||||
|
||||
// CollapsedLabel names the selection once the pills no longer fit. It defaults to
|
||||
// "N items selected", which is right for a list of things and wrong for anything
|
||||
// that is not — a column picker reading "5 items selected" tells you the count and
|
||||
// hides what the count is OF.
|
||||
CollapsedLabel func(n int) string
|
||||
}
|
||||
|
||||
// Render draws the field and its panel.
|
||||
@@ -1211,7 +1217,7 @@ func (m *MultiSelect) Render(p FormMultiSelectProps) *vdom.VNode {
|
||||
AriaHasPopup: "listbox",
|
||||
},
|
||||
vdom.Div(face...),
|
||||
IconInline(chevron, 16, "text-neutral-400"),
|
||||
IconInline(chevron, 16, "text-ink-faint"),
|
||||
)
|
||||
if p.Disabled {
|
||||
trigger.Attrs["disabled"] = "disabled"
|
||||
@@ -1227,27 +1233,7 @@ func (m *MultiSelect) Render(p FormMultiSelectProps) *vdom.VNode {
|
||||
// user is looking at and asked for; quietly selecting the hidden ones too would be
|
||||
// a nasty surprise.
|
||||
if p.ShowSelectAll && len(filtered) > 0 {
|
||||
all := formAllSelected(filtered, p.Value)
|
||||
label := "Select All"
|
||||
if all {
|
||||
label = "Deselect All"
|
||||
}
|
||||
mods := []vdom.Mod{vdom.Attr("type", "button"), vdom.Attr("class", formSelectAllBtn), vdom.Text(label)}
|
||||
if p.OnChange != nil {
|
||||
oc, cur, opts, allNow := p.OnChange, p.Value, filtered, all
|
||||
mods = append(mods, vdom.On(vdom.EVENT_CLICK, func() {
|
||||
if allNow {
|
||||
next := cur
|
||||
for _, o := range opts {
|
||||
next = formRemoveStr(next, o.Value)
|
||||
}
|
||||
oc(next)
|
||||
return
|
||||
}
|
||||
oc(formSelectAllValues(opts, cur))
|
||||
}))
|
||||
}
|
||||
rows = append(rows, vdom.Button(mods...))
|
||||
rows = append(rows, m.selectAllButton(filtered, p.Value, p.OnChange))
|
||||
}
|
||||
|
||||
if len(filtered) == 0 {
|
||||
@@ -1276,6 +1262,34 @@ func (m *MultiSelect) Render(p FormMultiSelectProps) *vdom.VNode {
|
||||
)
|
||||
}
|
||||
|
||||
// selectAllButton is the row at the top of the panel.
|
||||
//
|
||||
// It acts on what is VISIBLE. Selecting all of a filtered list is what the user is
|
||||
// looking at and asked for; quietly selecting the hidden ones too would be a nasty
|
||||
// surprise, and one they would not discover until they saved.
|
||||
func (m *MultiSelect) selectAllButton(filtered []FormSelectOption, value []string, onChange func([]string)) *vdom.VNode {
|
||||
all := formAllSelected(filtered, value)
|
||||
label := "Select All"
|
||||
if all {
|
||||
label = "Deselect All"
|
||||
}
|
||||
mods := []vdom.Mod{vdom.Attr("type", "button"), vdom.Attr("class", formSelectAllBtn), vdom.Text(label)}
|
||||
if onChange != nil {
|
||||
mods = append(mods, vdom.On(vdom.EVENT_CLICK, func() {
|
||||
if all {
|
||||
next := value
|
||||
for _, o := range filtered {
|
||||
next = formRemoveStr(next, o.Value)
|
||||
}
|
||||
onChange(next)
|
||||
return
|
||||
}
|
||||
onChange(formSelectAllValues(filtered, value))
|
||||
}))
|
||||
}
|
||||
return vdom.Button(mods...)
|
||||
}
|
||||
|
||||
// triggerFace is what the closed field shows: a placeholder, a row of removable pills,
|
||||
// or "N items selected" when the pills will not fit.
|
||||
//
|
||||
@@ -1286,7 +1300,7 @@ func (m *MultiSelect) Render(p FormMultiSelectProps) *vdom.VNode {
|
||||
func (m *MultiSelect) triggerFace(p FormMultiSelectProps, selected []FormSelectOption, maxTags int) []vdom.Mod {
|
||||
if len(selected) == 0 {
|
||||
return []vdom.Mod{vdom.Span(
|
||||
vdom.Attr("class", "text-neutral-500 truncate"),
|
||||
vdom.Attr("class", "text-ink-muted truncate"),
|
||||
vdom.Text(pick(p.Placeholder, "Select options")),
|
||||
)}
|
||||
}
|
||||
@@ -1315,7 +1329,7 @@ func (m *MultiSelect) triggerFace(p FormMultiSelectProps, selected []FormSelectO
|
||||
ov, label := opt.Value, opt.Label
|
||||
remove := []vdom.Mod{
|
||||
vdom.Attr("type", "button"),
|
||||
vdom.Attr("class", "bg-transparent border-none p-0 cursor-pointer flex items-center hover:text-neutral-900"),
|
||||
vdom.Attr("class", "bg-transparent border-none p-0 cursor-pointer flex items-center hover:text-ink"),
|
||||
vdom.Attr("aria-label", "Remove "+label),
|
||||
IconInline("xmark", 10, ""),
|
||||
}
|
||||
@@ -1334,10 +1348,14 @@ func (m *MultiSelect) triggerFace(p FormMultiSelectProps, selected []FormSelectO
|
||||
))
|
||||
}
|
||||
|
||||
label := itemsSelected
|
||||
if p.CollapsedLabel != nil {
|
||||
label = p.CollapsedLabel
|
||||
}
|
||||
count := vdom.Span(
|
||||
vdom.WithRef(m.countRef),
|
||||
vdom.Attr("class", faceCls("truncate", !tooMany)),
|
||||
vdom.Text(itemsSelected(len(selected))),
|
||||
vdom.Text(label(len(selected))),
|
||||
)
|
||||
|
||||
m.collapseFace(tooMany)
|
||||
@@ -1390,7 +1408,7 @@ func itemsSelected(n int) string {
|
||||
return strconv.Itoa(n) + " items selected"
|
||||
}
|
||||
|
||||
const formMultiSelectTag = "inline-flex items-center gap-0.5 rounded-default bg-neutral-200 py-0.5 text-neutral-700 whitespace-nowrap leading-none"
|
||||
const formMultiSelectTag = "inline-flex items-center gap-0.5 rounded-default bg-surface-strong py-0.5 text-ink-soft whitespace-nowrap leading-none"
|
||||
|
||||
func truncateRunes(s string, max int) string {
|
||||
if max <= 0 {
|
||||
@@ -1543,18 +1561,15 @@ var formTimezones = []FormSelectOption{
|
||||
{Value: "Pacific/Honolulu", Label: "Hawaii"},
|
||||
}
|
||||
|
||||
// -- intentionally omitted (no neutral-runtime equivalent) ---------------------
|
||||
// -- the rest of the TSX kit ---------------------------------------------------
|
||||
//
|
||||
// NOTE: the following TSX exports are omitted. They depend on capabilities the
|
||||
// neutral vdom runtime does not provide, and have no meaningful static form:
|
||||
// These four were once listed here as unportable — "no neutral-runtime equivalent".
|
||||
// That was true before the host API existed. It is not true now, and they all live in
|
||||
// forms_async.go and signaturepad.go:
|
||||
//
|
||||
// - FormSignaturePad — freehand drawing on a <canvas> 2D context with
|
||||
// mouse/touch tracking and SVG serialization.
|
||||
// - FormAsyncCombobox — debounced async option loading via Promises/timers.
|
||||
// - FormMultiSelectTrigger — a custom-trigger multi-select popover (Portal-free
|
||||
// but still driven by document listeners + refs); its
|
||||
// selection model is already covered by FormMultiSelect.
|
||||
// - handleTaxIdInput / handleRateInput — standalone oninput handlers that mutate
|
||||
// a DOM element in place; with no component consumer in
|
||||
// this file and no DOM handle in handlers, they don't
|
||||
// port. (Their masking logic mirrors formClean* above.)
|
||||
// - SignaturePad — draws into an SVG rather than a canvas, so the thing you
|
||||
// sign IS the value the caller stores.
|
||||
// - AsyncCombobox — debounced, and it discards out-of-order responses.
|
||||
// - MultiSelectTrigger — MultiSelect behind a trigger of your own.
|
||||
// - MaskTaxID / MaskRate — the TSX's oninput handlers, as pure functions of the
|
||||
// string. Testable, reusable, and they run on the server.
|
||||
|
||||
394
go/webui/forms_async.go
Normal file
394
go/webui/forms_async.go
Normal file
@@ -0,0 +1,394 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"kjol/vdom"
|
||||
"kjol/wasmruntime"
|
||||
)
|
||||
|
||||
// The three form controls the first pass left out, plus the two input masks. All of
|
||||
// them were listed as "no neutral-runtime equivalent" — which was true before the host
|
||||
// API existed, and is not true now.
|
||||
|
||||
// ---- AsyncCombobox: options fetched as you type --------------------------
|
||||
|
||||
// AsyncCombobox is a combobox whose options come from somewhere else: a search endpoint,
|
||||
// a database, a third-party API. You type, it asks, the answers appear.
|
||||
//
|
||||
// The asking is DEBOUNCED and the answers are ORDERED. Both matter and both are easy to
|
||||
// get wrong: without debouncing, a six-letter query is six requests; without ordering, a
|
||||
// slow response to "ab" can land after a fast one to "abcdef" and overwrite it, leaving
|
||||
// the user staring at results for a query they finished typing a second ago. Every
|
||||
// response carries the query it was for, and one that no longer matches the box is
|
||||
// dropped.
|
||||
//
|
||||
// Create it once, alongside your signals — never inside a render.
|
||||
type AsyncCombobox struct {
|
||||
f *Floating
|
||||
|
||||
query *vdom.Signal[string]
|
||||
results *vdom.Signal[[]FormSelectOption]
|
||||
loading *vdom.Signal[bool]
|
||||
active *vdom.Signal[int]
|
||||
inputRef *vdom.Ref
|
||||
|
||||
timer int
|
||||
minChars int
|
||||
debounce int
|
||||
search func(query string, done func([]FormSelectOption))
|
||||
}
|
||||
|
||||
// AsyncComboboxOptions configures NewAsyncCombobox.
|
||||
type AsyncComboboxOptions struct {
|
||||
// Search is asked for options. Call done with the results — from a goroutine, from a
|
||||
// fetch callback, whenever. Call it exactly once; calling it late is fine, since a
|
||||
// stale answer is discarded rather than shown.
|
||||
Search func(query string, done func([]FormSelectOption))
|
||||
|
||||
MinChars int // don't search below this many characters (default 2)
|
||||
DebounceMs int // wait this long after the last keystroke (default 200)
|
||||
Placement string
|
||||
OnOpenChange func(bool)
|
||||
}
|
||||
|
||||
// NewAsyncCombobox creates an async combobox.
|
||||
func NewAsyncCombobox(o AsyncComboboxOptions) *AsyncCombobox {
|
||||
c := &AsyncCombobox{
|
||||
query: vdom.NewSignal(""),
|
||||
results: vdom.NewSignal([]FormSelectOption{}),
|
||||
loading: vdom.NewSignal(false),
|
||||
active: vdom.NewSignal(-1),
|
||||
inputRef: vdom.NewRef(),
|
||||
minChars: o.MinChars,
|
||||
debounce: o.DebounceMs,
|
||||
search: o.Search,
|
||||
}
|
||||
if c.minChars == 0 {
|
||||
c.minChars = 2
|
||||
}
|
||||
if c.debounce == 0 {
|
||||
c.debounce = 200
|
||||
}
|
||||
c.f = NewFloating(FloatingOptions{
|
||||
Placement: pick(o.Placement, PlacementBottomStart),
|
||||
Offset: 4,
|
||||
ConstrainToViewport: true,
|
||||
Standalone: true,
|
||||
OnOpenChange: func(open bool) {
|
||||
if !open {
|
||||
c.active.Set(-1)
|
||||
}
|
||||
if o.OnOpenChange != nil {
|
||||
o.OnOpenChange(open)
|
||||
}
|
||||
},
|
||||
})
|
||||
return c
|
||||
}
|
||||
|
||||
// IsOpen / Close drive the panel.
|
||||
func (c *AsyncCombobox) IsOpen() bool { return c.f.IsOpen() }
|
||||
func (c *AsyncCombobox) Close() { c.f.Hide() }
|
||||
|
||||
// Dispose cancels a pending search and removes the panel's listeners.
|
||||
func (c *AsyncCombobox) Dispose() {
|
||||
wasmruntime.ClearTimeout(c.timer)
|
||||
c.f.Dispose()
|
||||
}
|
||||
|
||||
// FormAsyncComboboxProps configures a render.
|
||||
type FormAsyncComboboxProps struct {
|
||||
Placeholder string
|
||||
// OnSelect fires when an option is chosen. The box shows the option's label.
|
||||
OnSelect func(opt FormSelectOption)
|
||||
Disabled bool
|
||||
Small bool
|
||||
Class string
|
||||
FieldWidth string
|
||||
// EmptyMessage is shown when a search returns nothing (default "No matches").
|
||||
EmptyMessage string
|
||||
}
|
||||
|
||||
// Render draws the field and its panel.
|
||||
func (c *AsyncCombobox) Render(p FormAsyncComboboxProps) *vdom.VNode {
|
||||
input := vdom.Input(
|
||||
vdom.WithRef(c.inputRef),
|
||||
vdom.Attr("type", "text"),
|
||||
vdom.Attr("class", cx(formInputBase, formControlH(p.Small), "border-none bg-transparent p-0 shadow-none focus-visible:outline-none")),
|
||||
vdom.Attr("placeholder", pick(p.Placeholder, "Search…")),
|
||||
vdom.Attr("autocomplete", "off"),
|
||||
vdom.Attr("spellcheck", "false"),
|
||||
vdom.Prop("value", c.query.Get()),
|
||||
vdom.OnEvent(vdom.EVENT_INPUT, func(e vdom.Event) { c.onInput(e.Value()) }),
|
||||
vdom.OnEvent(vdom.EVENT_KEYDOWN, func(e vdom.Event) { c.onKey(e, p) }),
|
||||
)
|
||||
if p.Disabled {
|
||||
input.Attrs["disabled"] = "disabled"
|
||||
}
|
||||
|
||||
pad := "p-2"
|
||||
if p.Small {
|
||||
pad = "p-1"
|
||||
}
|
||||
trigger := c.f.Trigger(FloatingTriggerProps{
|
||||
Tag: "div",
|
||||
Class: cx(formTriggerBase, formControlH(p.Small), pad),
|
||||
AriaHasPopup: "listbox",
|
||||
// The field is not a switch — see FloatingTriggerProps.NoToggle.
|
||||
NoToggle: true,
|
||||
},
|
||||
vdom.Div(vdom.Attr("class", "min-w-0 flex-1"), input),
|
||||
c.statusIcon(),
|
||||
)
|
||||
|
||||
return vdom.Div(
|
||||
vdom.Attr("class", formComboboxRootCls(p.Class, p.FieldWidth)),
|
||||
trigger,
|
||||
c.panel(p),
|
||||
)
|
||||
}
|
||||
|
||||
func (c *AsyncCombobox) statusIcon() *vdom.VNode {
|
||||
if c.loading.Get() {
|
||||
return vdom.Span(vdom.Attr("class", "shrink-0 animate-spin text-ink-faint"), IconInline("circle-notch", 14, ""))
|
||||
}
|
||||
return IconInline("magnifying-glass", 14, "shrink-0 text-ink-faint")
|
||||
}
|
||||
|
||||
func (c *AsyncCombobox) panel(p FormAsyncComboboxProps) *vdom.VNode {
|
||||
rows := []*vdom.VNode{}
|
||||
|
||||
switch {
|
||||
case c.loading.Get():
|
||||
rows = append(rows, vdom.Div(vdom.Attr("class", formDropdownNoResults), vdom.Text("Searching…")))
|
||||
case len(c.results.Get()) == 0:
|
||||
rows = append(rows, vdom.Div(vdom.Attr("class", formDropdownNoResults),
|
||||
vdom.Text(pick(p.EmptyMessage, "No matches"))))
|
||||
default:
|
||||
for i, opt := range c.results.Get() {
|
||||
i, opt := i, opt
|
||||
cls := formDropdownOption
|
||||
if i == c.active.Get() {
|
||||
cls = cx(cls, "bg-surface-raised")
|
||||
}
|
||||
rows = append(rows, vdom.Button(
|
||||
vdom.Attr("type", "button"),
|
||||
vdom.Attr("class", cls),
|
||||
vdom.Attr("role", "option"),
|
||||
vdom.On(vdom.EVENT_CLICK, func() { c.choose(opt, p) }),
|
||||
vdom.Text(opt.Label),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
return c.f.Panel(FloatingPanelProps{Class: formDropdown, Role: "listbox"}, rows...)
|
||||
}
|
||||
|
||||
func (c *AsyncCombobox) choose(opt FormSelectOption, p FormAsyncComboboxProps) {
|
||||
c.query.Set(opt.Label)
|
||||
c.f.Hide()
|
||||
if p.OnSelect != nil {
|
||||
p.OnSelect(opt)
|
||||
}
|
||||
}
|
||||
|
||||
// onInput debounces. A search per keystroke is a search per keystroke — and if the
|
||||
// search costs a network round-trip, typing "engineering" is twelve of them.
|
||||
func (c *AsyncCombobox) onInput(q string) {
|
||||
c.query.Set(q)
|
||||
c.active.Set(-1)
|
||||
wasmruntime.ClearTimeout(c.timer)
|
||||
|
||||
if len([]rune(strings.TrimSpace(q))) < c.minChars {
|
||||
c.loading.Set(false)
|
||||
c.results.Set([]FormSelectOption{})
|
||||
c.f.Hide()
|
||||
return
|
||||
}
|
||||
|
||||
c.loading.Set(true)
|
||||
c.f.Show()
|
||||
c.timer = wasmruntime.SetTimeout(c.debounce, func() { c.run(q) })
|
||||
}
|
||||
|
||||
// run performs the search, and DISCARDS an answer that arrived for a query the user has
|
||||
// since typed past. Out-of-order responses are the classic bug in this control.
|
||||
func (c *AsyncCombobox) run(q string) {
|
||||
if c.search == nil {
|
||||
c.loading.Set(false)
|
||||
return
|
||||
}
|
||||
c.search(q, func(opts []FormSelectOption) {
|
||||
if c.query.Get() != q {
|
||||
return // the box has moved on; this answer is for a question nobody is asking
|
||||
}
|
||||
c.loading.Set(false)
|
||||
c.results.Set(opts)
|
||||
if len(opts) > 0 {
|
||||
c.f.Show()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (c *AsyncCombobox) onKey(e vdom.Event, p FormAsyncComboboxProps) {
|
||||
res := c.results.Get()
|
||||
switch e.Key() {
|
||||
case vdom.KEY_ARROW_DOWN:
|
||||
e.PreventDefault()
|
||||
if len(res) > 0 {
|
||||
c.active.Set((c.active.Get() + 1) % len(res))
|
||||
}
|
||||
case vdom.KEY_ARROW_UP:
|
||||
e.PreventDefault()
|
||||
if len(res) > 0 {
|
||||
next := c.active.Get() - 1
|
||||
if next < 0 {
|
||||
next = len(res) - 1
|
||||
}
|
||||
c.active.Set(next)
|
||||
}
|
||||
case vdom.KEY_ENTER:
|
||||
if i := c.active.Get(); i >= 0 && i < len(res) {
|
||||
e.PreventDefault()
|
||||
c.choose(res[i], p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- MultiSelectTrigger: a multi-select behind your own trigger ----------
|
||||
|
||||
// MultiSelectTrigger is MultiSelect with the field replaced by whatever you like — a
|
||||
// button, an icon, a table header. The selection model is identical; only the thing you
|
||||
// click on differs. The AutoTable's column picker is one of these in spirit.
|
||||
type MultiSelectTrigger struct{ *MultiSelect }
|
||||
|
||||
// NewMultiSelectTrigger creates one.
|
||||
func NewMultiSelectTrigger(o DropdownOptions) *MultiSelectTrigger {
|
||||
return &MultiSelectTrigger{MultiSelect: NewMultiSelect(o)}
|
||||
}
|
||||
|
||||
// FormMultiSelectTriggerProps configures a render.
|
||||
type FormMultiSelectTriggerProps struct {
|
||||
// Trigger is your element. It is wrapped in the floating trigger, so it needs no
|
||||
// open/close wiring of its own.
|
||||
Trigger *vdom.VNode
|
||||
|
||||
Options []FormSelectOption
|
||||
Value []string
|
||||
OnChange func([]string)
|
||||
Searchable bool
|
||||
SearchPlaceholder string
|
||||
ShowSelectAll bool
|
||||
Small bool
|
||||
Class string
|
||||
PanelClass string
|
||||
}
|
||||
|
||||
// Render draws the caller's trigger and the panel.
|
||||
func (m *MultiSelectTrigger) Render(p FormMultiSelectTriggerProps) *vdom.VNode {
|
||||
trigger := m.f.Trigger(FloatingTriggerProps{
|
||||
Tag: "div",
|
||||
Class: cx("inline-flex cursor-pointer items-center", p.Class),
|
||||
AriaHasPopup: "listbox",
|
||||
}, p.Trigger)
|
||||
|
||||
filtered := m.filter(p.Options)
|
||||
rows := make([]*vdom.VNode, 0, len(filtered)+2)
|
||||
if p.Searchable && m.IsOpen() {
|
||||
rows = append(rows, m.searchBox(p.SearchPlaceholder, false, len(filtered)))
|
||||
}
|
||||
if p.ShowSelectAll && len(filtered) > 0 {
|
||||
rows = append(rows, m.selectAllButton(filtered, p.Value, p.OnChange))
|
||||
}
|
||||
if len(filtered) == 0 {
|
||||
rows = append(rows, m.noResults(false))
|
||||
}
|
||||
for i := range filtered {
|
||||
opt := filtered[i]
|
||||
on := formContainsStr(p.Value, opt.Value)
|
||||
rows = append(rows, m.optionButton(opt, i, on, false, p.Small, func() {
|
||||
if p.OnChange != nil {
|
||||
p.OnChange(formToggleStr(p.Value, opt.Value))
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
return vdom.Div(vdom.Attr("class", "relative inline-flex"),
|
||||
trigger,
|
||||
m.f.Panel(FloatingPanelProps{Class: cx(formDropdown, pick(p.PanelClass, "min-w-48")), Role: "listbox"}, rows...),
|
||||
)
|
||||
}
|
||||
|
||||
// ---- input masks --------------------------------------------------------
|
||||
|
||||
// MaskTaxID formats a US tax ID as the user types: 12-3456789.
|
||||
//
|
||||
// The TSX equivalents (handleTaxIdInput / handleRateInput) were oninput handlers that
|
||||
// reached into the event and rewrote the element's value in place. These are pure
|
||||
// functions of the string instead — they can be tested, reused on the server, and
|
||||
// composed — and the caller wires one into an input the ordinary way:
|
||||
//
|
||||
// ui.FormInput(ui.FormInputProps{
|
||||
// Value: taxID.Get(),
|
||||
// OnInput: func(v string) { taxID.Set(ui.MaskTaxID(v)) },
|
||||
// })
|
||||
func MaskTaxID(s string) string {
|
||||
digits := keepDigits(s)
|
||||
if len(digits) > 9 {
|
||||
digits = digits[:9]
|
||||
}
|
||||
if len(digits) <= 2 {
|
||||
return digits
|
||||
}
|
||||
return digits[:2] + "-" + digits[2:]
|
||||
}
|
||||
|
||||
// MaskRate formats a rate as it is typed: digits, one decimal point, at most three
|
||||
// decimal places, no leading zeros.
|
||||
func MaskRate(s string) string {
|
||||
var b strings.Builder
|
||||
seenDot := false
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
switch {
|
||||
case c >= '0' && c <= '9':
|
||||
b.WriteByte(c)
|
||||
case c == '.' && !seenDot:
|
||||
seenDot = true
|
||||
b.WriteByte(c)
|
||||
}
|
||||
}
|
||||
out := b.String()
|
||||
|
||||
if i := strings.IndexByte(out, '.'); i >= 0 {
|
||||
whole, frac := out[:i], out[i+1:]
|
||||
if len(frac) > 3 {
|
||||
frac = frac[:3]
|
||||
}
|
||||
out = trimLeadingZeros(whole) + "." + frac
|
||||
} else {
|
||||
out = trimLeadingZeros(out)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func keepDigits(s string) string {
|
||||
var b strings.Builder
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] >= '0' && s[i] <= '9' {
|
||||
b.WriteByte(s[i])
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// trimLeadingZeros drops leading zeros but keeps a lone "0" — "007" is 7, and "0" is 0,
|
||||
// but "" is not a number the user meant to type.
|
||||
func trimLeadingZeros(s string) string {
|
||||
i := 0
|
||||
for i < len(s)-1 && s[i] == '0' {
|
||||
i++
|
||||
}
|
||||
return s[i:]
|
||||
}
|
||||
163
go/webui/forms_async_test.go
Normal file
163
go/webui/forms_async_test.go
Normal file
@@ -0,0 +1,163 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"kjol/vdom"
|
||||
)
|
||||
|
||||
// ---- input masks ----
|
||||
|
||||
func TestMaskTaxID(t *testing.T) {
|
||||
for _, tc := range []struct{ in, want string }{
|
||||
{"", ""},
|
||||
{"1", "1"},
|
||||
{"12", "12"},
|
||||
{"123", "12-3"},
|
||||
{"123456789", "12-3456789"},
|
||||
{"12-3456789", "12-3456789"}, // idempotent: re-masking its own output
|
||||
{"1234567890123", "12-3456789"}, // nine digits, no more
|
||||
{"ab12cd3456789xy", "12-3456789"},
|
||||
} {
|
||||
if got := MaskTaxID(tc.in); got != tc.want {
|
||||
t.Errorf("MaskTaxID(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaskRate(t *testing.T) {
|
||||
for _, tc := range []struct{ in, want string }{
|
||||
{"", ""},
|
||||
{"5", "5"},
|
||||
{"5.25", "5.25"},
|
||||
{"5.2555", "5.255"}, // three decimals, no more
|
||||
{"5.2.5", "5.25"}, // one decimal point, no more
|
||||
{"007", "7"}, // no leading zeros...
|
||||
{"0", "0"}, // ...but a lone zero is a number
|
||||
{"0.5", "0.5"}, // and so is a leading zero before a point
|
||||
{"$1,2a3.45", "123.45"},
|
||||
{"5.25", "5.25"}, // idempotent
|
||||
} {
|
||||
if got := MaskRate(tc.in); got != tc.want {
|
||||
t.Errorf("MaskRate(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A mask is applied to its own output on every keystroke, so anything that is not
|
||||
// idempotent corrupts the field as you type.
|
||||
func TestMasksAreIdempotent(t *testing.T) {
|
||||
for _, in := range []string{"123456789", "12-345", "1", ""} {
|
||||
once := MaskTaxID(in)
|
||||
if twice := MaskTaxID(once); twice != once {
|
||||
t.Errorf("MaskTaxID is not idempotent: %q -> %q -> %q", in, once, twice)
|
||||
}
|
||||
}
|
||||
for _, in := range []string{"5.255", "0.5", "007", "12"} {
|
||||
once := MaskRate(in)
|
||||
if twice := MaskRate(once); twice != once {
|
||||
t.Errorf("MaskRate is not idempotent: %q -> %q -> %q", in, once, twice)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- async combobox ----
|
||||
|
||||
func asyncOpts(labels ...string) []FormSelectOption {
|
||||
out := make([]FormSelectOption, len(labels))
|
||||
for i, l := range labels {
|
||||
out[i] = FormSelectOption{Value: strings.ToLower(l), Label: l}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// The classic bug in a search-as-you-type box: a SLOW answer to an early query lands
|
||||
// after a FAST answer to the query the user actually finished typing, and overwrites it.
|
||||
// The user is then reading results for "ab" while the box says "abcdef".
|
||||
//
|
||||
// Every response carries the query it was for; one that no longer matches is dropped.
|
||||
func TestAsyncComboboxDropsStaleResponses(t *testing.T) {
|
||||
var pending []func([]FormSelectOption) // answers we have not given yet
|
||||
var queries []string
|
||||
|
||||
c := NewAsyncCombobox(AsyncComboboxOptions{
|
||||
MinChars: 2,
|
||||
Search: func(q string, done func([]FormSelectOption)) {
|
||||
queries = append(queries, q)
|
||||
pending = append(pending, done)
|
||||
},
|
||||
})
|
||||
|
||||
// Type "ab", then "abcdef". (run() is called directly: the debounce timer is a
|
||||
// browser timer, and this test has no browser.)
|
||||
c.query.Set("ab")
|
||||
c.run("ab")
|
||||
c.query.Set("abcdef")
|
||||
c.run("abcdef")
|
||||
|
||||
if len(pending) != 2 {
|
||||
t.Fatalf("expected 2 searches, got %d (%v)", len(pending), queries)
|
||||
}
|
||||
|
||||
// The answer to "abcdef" comes back first...
|
||||
pending[1](asyncOpts("Abcdef Ltd"))
|
||||
if got := c.results.Get(); len(got) != 1 || got[0].Label != "Abcdef Ltd" {
|
||||
t.Fatalf("the current query's results were not shown: %v", got)
|
||||
}
|
||||
|
||||
// ...and the slow answer to "ab" arrives afterwards. It must be DISCARDED.
|
||||
pending[0](asyncOpts("Ab Corp", "Abacus"))
|
||||
|
||||
got := c.results.Get()
|
||||
if len(got) != 1 || got[0].Label != "Abcdef Ltd" {
|
||||
t.Errorf("a stale response overwrote the current results: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Below MinChars there is no search at all — a one-character query against a big table
|
||||
// is a scan, and every user who ever types starts by typing one character.
|
||||
func TestAsyncComboboxRespectsMinChars(t *testing.T) {
|
||||
searched := 0
|
||||
c := NewAsyncCombobox(AsyncComboboxOptions{
|
||||
MinChars: 3,
|
||||
Search: func(string, func([]FormSelectOption)) { searched++ },
|
||||
})
|
||||
|
||||
c.onInput("a")
|
||||
c.onInput("ab")
|
||||
if searched != 0 {
|
||||
t.Errorf("searched %d times below MinChars", searched)
|
||||
}
|
||||
if c.IsOpen() {
|
||||
t.Error("the panel opened with nothing to show")
|
||||
}
|
||||
}
|
||||
|
||||
// Choosing an option closes the box and reports the whole option, not just its value —
|
||||
// the caller usually needs the label back to display it.
|
||||
func TestAsyncComboboxSelect(t *testing.T) {
|
||||
var picked FormSelectOption
|
||||
c := NewAsyncCombobox(AsyncComboboxOptions{
|
||||
Search: func(_ string, done func([]FormSelectOption)) { done(asyncOpts("Ada Lovelace")) },
|
||||
})
|
||||
|
||||
c.query.Set("ada")
|
||||
c.run("ada")
|
||||
c.f.Show()
|
||||
|
||||
node := c.Render(FormAsyncComboboxProps{OnSelect: func(o FormSelectOption) { picked = o }})
|
||||
if !findAndClick(node, "Ada Lovelace") {
|
||||
t.Fatalf("no result row to click:\n%s", vdom.RenderHTML(node))
|
||||
}
|
||||
|
||||
if picked.Value != "ada lovelace" {
|
||||
t.Errorf("OnSelect got %+v", picked)
|
||||
}
|
||||
if c.IsOpen() {
|
||||
t.Error("choosing an option should close the panel")
|
||||
}
|
||||
if c.query.Get() != "Ada Lovelace" {
|
||||
t.Errorf("the field should show the chosen label, got %q", c.query.Get())
|
||||
}
|
||||
}
|
||||
@@ -230,12 +230,12 @@ const (
|
||||
FuzzyDisplayNone = "none" // render only the input (headless)
|
||||
)
|
||||
|
||||
const fuzzyInputCls = "bg-white block w-full border border-neutral-300 rounded-default shadow-xs text-sm p-2 h-[38px] focus:outline-2 focus:outline-offset-1 focus:outline-sky-500"
|
||||
const fuzzyInputCls = "bg-surface block w-full border border-line-strong rounded-default shadow-xs text-sm p-2 h-[38px] focus:outline-2 focus:outline-offset-1 focus:outline-sky-500"
|
||||
|
||||
// Mirror FormCombobox's dropdown styling: neutral hover / highlight.
|
||||
const fuzzyDropdownCls = "bg-white border border-neutral-300 rounded-default shadow-lg max-h-60 overflow-auto"
|
||||
const fuzzyDropdownOptionCls = "w-full text-left p-2 text-sm cursor-pointer flex items-center justify-between gap-2 bg-transparent border-none hover:bg-neutral-100 whitespace-nowrap"
|
||||
const fuzzyDropdownOptionHighlightCls = "bg-neutral-100"
|
||||
const fuzzyDropdownCls = "bg-surface border border-line-strong rounded-default shadow-lg max-h-60 overflow-auto"
|
||||
const fuzzyDropdownOptionCls = "w-full text-left p-2 text-sm cursor-pointer flex items-center justify-between gap-2 bg-transparent border-none hover:bg-surface-raised whitespace-nowrap"
|
||||
const fuzzyDropdownOptionHighlightCls = "bg-surface-raised"
|
||||
|
||||
// FuzzyMatchProps configures the FuzzyMatch component. Query / Open / Highlighted
|
||||
// are caller-held state (read at the call site); the On* callbacks report changes.
|
||||
@@ -262,7 +262,7 @@ func fuzzyHighlight(segments []FuzzySegment) []*vdom.VNode {
|
||||
out := make([]*vdom.VNode, 0, len(segments))
|
||||
for _, seg := range segments {
|
||||
if seg.Match {
|
||||
out = append(out, vdom.Span(vdom.Attr("class", "text-sky-700 font-semibold"), vdom.Text(seg.Text)))
|
||||
out = append(out, vdom.Span(vdom.Attr("class", "text-sky-700 dark:text-sky-400 font-semibold"), vdom.Text(seg.Text)))
|
||||
} else {
|
||||
out = append(out, vdom.Span(vdom.Text(seg.Text)))
|
||||
}
|
||||
@@ -275,7 +275,7 @@ func fuzzyScoreBadge(show bool, score int) *vdom.VNode {
|
||||
if !show {
|
||||
return nil
|
||||
}
|
||||
return vdom.Span(vdom.Attr("class", "ml-3 shrink-0 text-xs text-neutral-400"), vdom.Text(strconv.Itoa(score)))
|
||||
return vdom.Span(vdom.Attr("class", "ml-3 shrink-0 text-xs text-ink-faint"), vdom.Text(strconv.Itoa(score)))
|
||||
}
|
||||
|
||||
// fuzzyListView renders the inline "list" display. Before the user types, all
|
||||
@@ -296,12 +296,12 @@ func fuzzyListView(p FuzzyMatchProps, results []FuzzyRankedItem) *vdom.VNode {
|
||||
for _, r := range items {
|
||||
r := r
|
||||
li := []vdom.Mod{
|
||||
vdom.Attr("class", "flex items-center justify-between gap-3 px-2 py-1 rounded-default cursor-pointer hover:bg-neutral-100"),
|
||||
vdom.Attr("class", "flex items-center justify-between gap-3 px-2 py-1 rounded-default cursor-pointer hover:bg-surface-raised"),
|
||||
}
|
||||
if p.OnSelect != nil {
|
||||
li = append(li, vdom.On(vdom.EVENT_CLICK, func() { p.OnSelect(r.Value, r) }))
|
||||
}
|
||||
li = append(li, vdom.Span(kids([]vdom.Mod{vdom.Attr("class", "text-sm text-neutral-800")}, fuzzyHighlight(r.Segments))...))
|
||||
li = append(li, vdom.Span(kids([]vdom.Mod{vdom.Attr("class", "text-sm text-ink")}, fuzzyHighlight(r.Segments))...))
|
||||
if badge := fuzzyScoreBadge(p.ShowScores, r.Score); badge != nil {
|
||||
li = append(li, badge)
|
||||
}
|
||||
@@ -309,7 +309,7 @@ func fuzzyListView(p FuzzyMatchProps, results []FuzzyRankedItem) *vdom.VNode {
|
||||
}
|
||||
outer = append(outer, vdom.Ul(ul...))
|
||||
} else if strings.TrimSpace(p.Query) != "" {
|
||||
outer = append(outer, vdom.P(vdom.Attr("class", "text-sm text-neutral-500 italic"),
|
||||
outer = append(outer, vdom.P(vdom.Attr("class", "text-sm text-ink-muted italic"),
|
||||
vdom.Text(`No matches for "`+p.Query+`".`)))
|
||||
}
|
||||
return vdom.Div(outer...)
|
||||
@@ -338,7 +338,7 @@ func fuzzyDropdownView(p FuzzyMatchProps, results []FuzzyRankedItem) *vdom.VNode
|
||||
if p.OnSelect != nil {
|
||||
btnMods = append(btnMods, vdom.On(vdom.EVENT_CLICK, func() { p.OnSelect(r.Value, r) }))
|
||||
}
|
||||
btnMods = append(btnMods, vdom.Span(kids([]vdom.Mod{vdom.Attr("class", "text-neutral-800")}, fuzzyHighlight(r.Segments))...))
|
||||
btnMods = append(btnMods, vdom.Span(kids([]vdom.Mod{vdom.Attr("class", "text-ink")}, fuzzyHighlight(r.Segments))...))
|
||||
if badge := fuzzyScoreBadge(p.ShowScores, r.Score); badge != nil {
|
||||
btnMods = append(btnMods, badge)
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ func CodeBox(code, class string) *vdom.VNode {
|
||||
func PageHeader(text, class string) *vdom.VNode {
|
||||
return vdom.Header(vdom.Attr("class", class),
|
||||
vdom.Div(vdom.Attr("class", "mt-1"),
|
||||
vdom.H1(vdom.Attr("class", "text-center text-2xl font-light text-neutral-800 mb-2"), vdom.Text(text)),
|
||||
vdom.H1(vdom.Attr("class", "text-center text-2xl font-light text-ink mb-2"), vdom.Text(text)),
|
||||
vdom.Hr(vdom.Attr("class", "text-neutral-200 mb-2")),
|
||||
),
|
||||
)
|
||||
@@ -34,7 +34,7 @@ func PageHeader(text, class string) *vdom.VNode {
|
||||
func PageLink(href string, newTab bool, class string, children ...*vdom.VNode) *vdom.VNode {
|
||||
mods := []vdom.Mod{
|
||||
vdom.Attr("href", href),
|
||||
vdom.Attr("class", cx("text-sky-700 hover:text-sky-800 hover:underline hover:decoration-1", class)),
|
||||
vdom.Attr("class", cx("text-sky-700 dark:text-sky-400 hover:text-sky-800 hover:underline hover:decoration-1", class)),
|
||||
}
|
||||
if newTab {
|
||||
mods = append(mods, vdom.Attr("target", "_blank"), vdom.Attr("rel", "noopener noreferrer"))
|
||||
@@ -58,17 +58,17 @@ type BreadcrumbItem struct {
|
||||
// Breadcrumbs renders a chevron-separated crumb trail; the last item is bold and
|
||||
// not linked.
|
||||
func Breadcrumbs(items []BreadcrumbItem) *vdom.VNode {
|
||||
mods := []vdom.Mod{vdom.Attr("class", "flex flex-row items-center text-neutral-400 text-xs")}
|
||||
mods := []vdom.Mod{vdom.Attr("class", "flex flex-row items-center text-ink-faint text-xs")}
|
||||
for i, crumb := range items {
|
||||
if i != len(items)-1 {
|
||||
mods = append(mods, vdom.Span(vdom.Attr("class", "flex items-center"),
|
||||
vdom.A(vdom.Attr("href", crumb.URL),
|
||||
vdom.Attr("class", "text-neutral-500 cursor-pointer no-underline hover:text-neutral-700 hover:underline"),
|
||||
vdom.Attr("class", "text-ink-muted cursor-pointer no-underline hover:text-ink-soft hover:underline"),
|
||||
vdom.Text(crumb.DisplayText)),
|
||||
Icon("chevron-right", 12, "mx-[0.15rem] opacity-50"),
|
||||
))
|
||||
} else {
|
||||
mods = append(mods, vdom.Span(vdom.Attr("class", "text-neutral-700 font-medium"), vdom.Text(crumb.DisplayText)))
|
||||
mods = append(mods, vdom.Span(vdom.Attr("class", "text-ink-soft font-medium"), vdom.Text(crumb.DisplayText)))
|
||||
}
|
||||
}
|
||||
return vdom.Div(mods...)
|
||||
|
||||
@@ -24,6 +24,18 @@ func RegisterIcon(name, viewBox, content string) {
|
||||
iconRegistry[name] = iconDef{viewBox: viewBox, content: content}
|
||||
}
|
||||
|
||||
// HasIcon reports whether a name resolves to an icon.
|
||||
//
|
||||
// It exists so an app can TEST that the names it uses are real. An unknown name renders
|
||||
// a correctly-sized but empty box — the layout survives, which is the right runtime
|
||||
// behaviour and a terrible debugging experience: the icon is simply not there, and
|
||||
// nothing anywhere says why. A one-line test over the names you reference turns that
|
||||
// into a build failure.
|
||||
func HasIcon(name string) bool {
|
||||
_, ok := iconRegistry[name]
|
||||
return ok
|
||||
}
|
||||
|
||||
// Icon renders a named icon at the given pixel size (0 => 16). Extra classes are
|
||||
// appended. Unknown names render an empty, correctly-sized box (never blanks the
|
||||
// layout). Mirrors Icons.tsx's <svg fill=currentColor width height>innerHTML</svg>.
|
||||
@@ -59,7 +71,7 @@ func IconSuccess(name string, size int, class string) *vdom.VNode {
|
||||
return Icon(name, size, cx("inline-block align-middle text-green-600", class))
|
||||
}
|
||||
func IconError(name string, size int, class string) *vdom.VNode {
|
||||
return Icon(name, size, cx("inline-block align-middle text-red-600", class))
|
||||
return Icon(name, size, cx("inline-block align-middle text-red-600 dark:text-red-400", class))
|
||||
}
|
||||
|
||||
// IconContainer is a flex row that vertically centers an icon + text.
|
||||
@@ -136,6 +148,32 @@ func init() {
|
||||
reg("globe", "M12 21a9 9 0 0 0 8.72-6.75M12 21a9 9 0 0 1-8.72-6.75M12 21c2.49 0 4.5-4.03 4.5-9S14.49 3 12 3m0 18c-2.49 0-4.5-4.03-4.5-9S9.51 3 12 3m0 0a9 9 0 0 1 7.84 4.58M12 3a9 9 0 0 0-7.84 4.58m15.68 0A11.95 11.95 0 0 1 12 10.5c-3 0-5.74-1.1-7.84-2.92m15.68 0A8.96 8.96 0 0 1 21 12c0 .78-.1 1.53-.28 2.25m0 0A17.92 17.92 0 0 1 12 16.5c-3.16 0-6.13-.82-8.72-2.25m0 0A9.02 9.02 0 0 1 3 12c0-1.6.42-3.11 1.16-4.42")
|
||||
reg("user", "M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.5 20.12a7.5 7.5 0 0 1 15 0A17.93 17.93 0 0 1 12 21.75c-2.68 0-5.22-.58-7.5-1.63Z")
|
||||
|
||||
// --- documentation / section icons ---
|
||||
reg("book-open", "M12 6.04A8.97 8.97 0 0 0 6 3.75c-1.05 0-2.06.18-3 .51v14.25A8.99 8.99 0 0 1 6 18c2.3 0 4.4.87 6 2.29m0-14.25a8.97 8.97 0 0 1 6-2.29c1.05 0 2.06.18 3 .51v14.25A8.99 8.99 0 0 0 18 18a8.97 8.97 0 0 0-6 2.29m0-14.25v14.25")
|
||||
reg("chart-column", "M3.75 20.25h16.5M6.75 20.25v-6.75m4.5 6.75V8.25m4.5 12V4.5m4.5 15.75v-9")
|
||||
reg("server", "M3.75 6.75h16.5v4.5H3.75v-4.5Zm0 6h16.5v4.5H3.75v-4.5ZM6.75 9h.01M6.75 15h.01")
|
||||
reg("cloud-arrow-down", "M12 9.75v6.75m0 0-3-3m3 3 3-3m-8.25 6a4.5 4.5 0 0 1-1.41-8.78 5.25 5.25 0 0 1 10.23-2.33 3 3 0 0 1 3.76 3.85A3.75 3.75 0 0 1 18 19.5H6.75Z")
|
||||
reg("squares", "M3.75 6A2.25 2.25 0 0 1 6 3.75h2.25A2.25 2.25 0 0 1 10.5 6v2.25a2.25 2.25 0 0 1-2.25 2.25H6a2.25 2.25 0 0 1-2.25-2.25V6Zm0 9.75A2.25 2.25 0 0 1 6 13.5h2.25a2.25 2.25 0 0 1 2.25 2.25V18A2.25 2.25 0 0 1 8.25 20.25H6A2.25 2.25 0 0 1 3.75 18v-2.25ZM13.5 6a2.25 2.25 0 0 1 2.25-2.25H18A2.25 2.25 0 0 1 20.25 6v2.25A2.25 2.25 0 0 1 18 10.5h-2.25a2.25 2.25 0 0 1-2.25-2.25V6Zm0 9.75a2.25 2.25 0 0 1 2.25-2.25H18a2.25 2.25 0 0 1 2.25 2.25V18A2.25 2.25 0 0 1 18 20.25h-2.25A2.25 2.25 0 0 1 13.5 18v-2.25Z")
|
||||
reg("layers", "m2.25 12 9 5.25L20.25 12M2.25 7.5l9-5.25 9 5.25-9 5.25-9-5.25Zm0 9 9 5.25 9-5.25")
|
||||
reg("table", "M3.75 5.25h16.5v13.5H3.75V5.25Zm0 4.5h16.5m-16.5 4.5h16.5M9.75 9.75v9")
|
||||
reg("code", "m6.75 7.5-4.5 4.5 4.5 4.5m10.5-9 4.5 4.5-4.5 4.5M14.25 3.75l-4.5 16.5")
|
||||
reg("bolt", "M14.25 2.25 4.5 13.5h6l-.75 8.25L19.5 10.5h-6l.75-8.25Z")
|
||||
// A keel is the spine of a hull — the thing everything else is built on. Which is
|
||||
// the whole idea behind the name, so the boat is not merely decorative.
|
||||
reg("sailboat", "M11.25 3.75v12M11.25 15.75H4.5l6.75-12M14.25 15.75h4.5l-4.5-7.5zM2.25 18.75h19.5l-2.4 3H4.65z")
|
||||
reg("cube", "m21 7.5-9-5.25L3 7.5m18 0-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-9v9")
|
||||
reg("shield-check", "M9 12.75 11.25 15 15 9.75m-3-7.04A11.96 11.96 0 0 1 3.6 6 12 12 0 0 0 3 9.75c0 5.59 3.82 10.29 9 11.62 5.18-1.33 9-6.03 9-11.62 0-1.31-.21-2.57-.6-3.75h-.15a11.96 11.96 0 0 1-8.25-3.29Z")
|
||||
|
||||
// --- theme ---
|
||||
reg("sun", "M12 3v1.5m0 15V21m9-9h-1.5m-15 0H3m15.36-6.36-1.06 1.06M6.7 17.3l-1.06 1.06m12.72 0-1.06-1.06M6.7 6.7 5.64 5.64M16.5 12a4.5 4.5 0 1 1-9 0 4.5 4.5 0 0 1 9 0Z")
|
||||
reg("moon", "M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79Z")
|
||||
|
||||
// --- search / loading ---
|
||||
reg("magnifying-glass", "M21 21l-5.2-5.2M17 10.5a6.5 6.5 0 1 1-13 0 6.5 6.5 0 0 1 13 0Z")
|
||||
// An open arc, so that spinning it actually reads as motion — a full ring rotating
|
||||
// looks like a ring standing still.
|
||||
reg("circle-notch", "M21 12a9 9 0 0 0-9-9")
|
||||
|
||||
// --- status (the toast set) ---
|
||||
reg("info", "M11.25 11.25h1.5v4.5M12 8.25h.01M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z")
|
||||
reg("circle-info", "M11.25 11.25h1.5v4.5M12 8.25h.01M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z")
|
||||
|
||||
@@ -57,11 +57,11 @@ import "kjol/vdom"
|
||||
// MenuDivider, MenuSection and MenuGroup stay free functions: they hold no state
|
||||
// and have nothing to close.
|
||||
|
||||
const menuCls = "bg-white rounded-default shadow-lg border border-neutral-200 p-1.5 min-w-48 max-h-96 overflow-y-auto"
|
||||
const menuItemCls = "flex items-center gap-2 w-full py-1.5 px-2 rounded-default text-sm text-left text-neutral-700 bg-transparent border-0 cursor-pointer hover:bg-neutral-100 hover:text-neutral-900 focus:bg-neutral-100 focus:outline-hidden disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
const menuDividerCls = "my-1 -mx-1.5 border-0 border-t border-neutral-200"
|
||||
const menuSectionCls = "pt-1.5 pb-0.5 px-2 text-[10px] font-semibold text-neutral-400 uppercase tracking-wide text-left"
|
||||
const menuSubmenuTriggerCls = "flex items-center justify-between gap-2 w-full py-1.5 px-2 rounded-default text-sm text-left text-neutral-700 cursor-pointer hover:bg-neutral-100 hover:text-neutral-900"
|
||||
const menuCls = "bg-surface rounded-default shadow-lg border border-line p-1.5 min-w-48 max-h-96 overflow-y-auto"
|
||||
const menuItemCls = "flex items-center gap-2 w-full py-1.5 px-2 rounded-default text-sm text-left text-ink-soft bg-transparent border-0 cursor-pointer hover:bg-surface-raised hover:text-ink focus:bg-surface-raised focus:outline-hidden disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
const menuDividerCls = "my-1 -mx-1.5 border-0 border-t border-line"
|
||||
const menuSectionCls = "pt-1.5 pb-0.5 px-2 text-[10px] font-semibold text-ink-faint uppercase tracking-wide text-left"
|
||||
const menuSubmenuTriggerCls = "flex items-center justify-between gap-2 w-full py-1.5 px-2 rounded-default text-sm text-left text-ink-soft cursor-pointer hover:bg-surface-raised hover:text-ink"
|
||||
|
||||
// defaultHoverCloseDelay is the TSX's 150ms grace period after the cursor leaves.
|
||||
const defaultHoverCloseDelay = 150
|
||||
@@ -375,7 +375,7 @@ func (m *Menu) Anchor(p MenuAnchorProps, children ...*vdom.VNode) *vdom.VNode {
|
||||
}
|
||||
mods = kids(mods, children)
|
||||
if target == "_blank" {
|
||||
mods = append(mods, Icon("arrow-right", 12, "shrink-0 ml-auto text-neutral-400"))
|
||||
mods = append(mods, Icon("arrow-right", 12, "shrink-0 ml-auto text-ink-faint"))
|
||||
}
|
||||
return vdom.A(mods...)
|
||||
}
|
||||
@@ -413,7 +413,7 @@ func (m *Menu) Submenu(p SubmenuProps, children ...*vdom.VNode) *vdom.VNode {
|
||||
OnClick: m.f.Toggle,
|
||||
},
|
||||
vdom.Span(label...),
|
||||
Icon("chevron-right", 16, "shrink-0 ml-auto text-neutral-400"),
|
||||
Icon("chevron-right", 16, "shrink-0 ml-auto text-ink-faint"),
|
||||
)
|
||||
menuSetAttr(trigger, "role", "menuitem")
|
||||
// Entering the trigger means entering the submenu; it must not let an ancestor
|
||||
|
||||
@@ -98,7 +98,7 @@ const modalContainerCenter = "items-center"
|
||||
|
||||
const modalBackdrop = "fixed inset-0 bg-black/30"
|
||||
|
||||
const modalBase = "relative bg-white shadow-md text-sm w-full rounded-default max-h-[calc(100dvh_-_5rem)] flex flex-col overflow-hidden"
|
||||
const modalBase = "relative bg-surface shadow-md text-sm w-full rounded-default max-h-[calc(100dvh_-_5rem)] flex flex-col overflow-hidden"
|
||||
|
||||
var modalSizes = map[ModalSize]string{
|
||||
ModalSmall: "max-w-md",
|
||||
@@ -113,19 +113,19 @@ var modalSizes = map[ModalSize]string{
|
||||
ModalFull: "max-w-none",
|
||||
}
|
||||
|
||||
const modalHeader = "flex items-center justify-between py-5 px-7 pb-4 border-b border-neutral-200 text-lg font-semibold text-text-heading"
|
||||
const modalHeader = "flex items-center justify-between py-5 px-7 pb-4 border-b border-line text-lg font-semibold text-text-heading"
|
||||
const modalHeaderCloseOnly = "flex items-center justify-end p-4 pb-1"
|
||||
const modalCloseBtn = "cursor-pointer text-neutral-500 bg-transparent border-0 p-0 leading-none hover:text-neutral-700"
|
||||
const modalCloseBtn = "cursor-pointer text-ink-muted bg-transparent border-0 p-0 leading-none hover:text-ink-soft"
|
||||
const modalBody = "px-7 py-6 overflow-y-auto flex-1 min-h-0 [background:linear-gradient(var(--color-white),var(--color-white))_bottom_/_100%_3rem_no-repeat_local,linear-gradient(to_bottom,transparent,var(--color-white))_bottom_/_100%_3rem_no-repeat_scroll,var(--color-white)]"
|
||||
const modalFooter = "py-4 px-7 pb-[calc(1rem_+_env(safe-area-inset-bottom,0px))] flex flex-row justify-end gap-2 border-t border-neutral-200 bg-neutral-50 rounded-b-default"
|
||||
const modalFooter = "py-4 px-7 pb-[calc(1rem_+_env(safe-area-inset-bottom,0px))] flex flex-row justify-end gap-2 border-t border-line bg-surface-muted rounded-b-default"
|
||||
const modalFooterSpacer = "h-2"
|
||||
|
||||
const modalWizardError = "flex items-center gap-2 py-3 px-8 text-sm text-red-700 bg-red-50 border-t border-red-200"
|
||||
const modalWizardErrorIcon = "shrink-0 text-red-500"
|
||||
const modalWizardError = "flex items-center gap-2 py-3 px-8 text-sm text-red-700 bg-red-50 dark:bg-red-950 border-t border-red-200 dark:border-red-900"
|
||||
const modalWizardErrorIcon = "shrink-0 text-red-500 dark:text-red-400"
|
||||
|
||||
// Confirm modal
|
||||
const modalConfirmWrap = "flex justify-end gap-2"
|
||||
const modalConfirmCancel = "py-2 px-4 text-sm border border-neutral-300 rounded-default bg-transparent cursor-pointer hover:bg-neutral-50"
|
||||
const modalConfirmCancel = "py-2 px-4 text-sm border border-line-strong rounded-default bg-transparent cursor-pointer hover:bg-surface-muted"
|
||||
const modalConfirmOkBase = "py-2 px-4 text-sm rounded-default border-0 cursor-pointer text-white"
|
||||
|
||||
var modalConfirmOkVariants = map[string]string{
|
||||
@@ -137,20 +137,20 @@ var modalConfirmOkVariants = map[string]string{
|
||||
const modalWizardHeader = "flex flex-col items-center gap-2 flex-1"
|
||||
const modalWizardTitleRow = "flex items-center justify-between w-full"
|
||||
const modalWizardTitle = "text-xl"
|
||||
const modalWizardStepName = "text-xs font-semibold text-neutral-600 uppercase tracking-wider"
|
||||
const modalWizardStepName = "text-xs font-semibold text-ink-soft uppercase tracking-wider"
|
||||
const modalWizardSteps = "flex items-center justify-between relative w-full max-w-64"
|
||||
const modalWizardTrack = "absolute top-1/2 left-0 right-0 h-0.5 bg-neutral-200 -translate-y-1/2"
|
||||
const modalWizardTrack = "absolute top-1/2 left-0 right-0 h-0.5 bg-surface-strong -translate-y-1/2"
|
||||
const modalWizardTrackFill = "h-full bg-primary transition-[width] duration-300 ease-in-out"
|
||||
const modalWizardStepWrap = "relative z-[1]"
|
||||
const modalStepIndicatorBase = "w-7 h-7 rounded-full flex items-center justify-center text-xs font-semibold border-2 shrink-0 transition-all duration-300 ease-in-out"
|
||||
const modalStepIndicatorPending = "border-neutral-300 text-neutral-400 bg-white"
|
||||
const modalStepIndicatorPending = "border-line-strong text-ink-faint bg-surface"
|
||||
const modalStepIndicatorActive = "bg-primary text-white border-primary"
|
||||
const modalStepIndicatorCompleted = "bg-primary text-white border-primary"
|
||||
|
||||
// Wizard footer
|
||||
const modalWizardFooter = "flex items-center justify-between w-full gap-2"
|
||||
const modalWizardBtnBase = "py-2 px-5 text-sm rounded-default cursor-pointer border-0 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
const modalWizardBtnBack = "bg-transparent border border-neutral-300 text-neutral-700 enabled:hover:bg-neutral-50"
|
||||
const modalWizardBtnBack = "bg-transparent border border-line-strong text-ink-soft enabled:hover:bg-surface-muted"
|
||||
const modalWizardBtnNext = "bg-neutral-800 text-white enabled:hover:bg-neutral-900"
|
||||
const modalWizardBtnFinish = "bg-primary text-white enabled:hover:bg-red-700"
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ package webui
|
||||
|
||||
import "kjol/vdom"
|
||||
|
||||
const popoverCls = "bg-white rounded-default shadow-lg border border-neutral-200"
|
||||
const popoverCls = "bg-surface rounded-default shadow-lg border border-line"
|
||||
|
||||
// TSX defaults: offset ?? 8, hoverDelay ?? 0, hoverCloseDelay ?? 150.
|
||||
const popoverOffset = 8
|
||||
|
||||
@@ -45,14 +45,14 @@ const (
|
||||
PrettyTableSizeSuperCompact PrettyTableSize = 2
|
||||
)
|
||||
|
||||
const ptTblContainer = "relative flex flex-col w-full h-full bg-white rounded-default overflow-hidden"
|
||||
const ptTblContainer = "relative flex flex-col w-full h-full bg-surface rounded-default overflow-hidden"
|
||||
const ptTblWrapper = "overflow-x-auto w-full"
|
||||
const ptTblBase = "min-w-full"
|
||||
const ptHeaderContent = "transition-transform duration-150 ease-in-out"
|
||||
const ptHeaderInnerBase = "flex justify-between gap-2 items-center"
|
||||
|
||||
var ptHeaderColorCls = map[PrettyTableHeaderColor]string{
|
||||
PrettyTableColorDefault: "bg-neutral-50",
|
||||
PrettyTableColorDefault: "bg-surface-muted",
|
||||
PrettyTableColorBlue: "bg-sky-700 text-white",
|
||||
PrettyTableColorGreen: "bg-green-700 text-white",
|
||||
PrettyTableColorGray: "bg-neutral-600 text-white",
|
||||
@@ -70,11 +70,11 @@ var ptHeaderTextCls = map[PrettyTableHeaderColor]string{
|
||||
// ptRowHoverCls is applied to the tbody when Hover is on (matches the TSX
|
||||
// PT_ROW_HOVER_CLS map).
|
||||
var ptRowHoverCls = map[PrettyTableHeaderColor]string{
|
||||
PrettyTableColorDefault: "[&_tr:hover]:!bg-neutral-200",
|
||||
PrettyTableColorBlue: "[&_tr:hover]:!bg-sky-100",
|
||||
PrettyTableColorGreen: "[&_tr:hover]:!bg-green-100",
|
||||
PrettyTableColorGray: "[&_tr:hover]:!bg-neutral-200",
|
||||
PrettyTableColorDarkBlue: "[&_tr:hover]:!bg-sky-100",
|
||||
PrettyTableColorDefault: "[&_tr:hover]:!bg-surface-strong",
|
||||
PrettyTableColorBlue: "[&_tr:hover]:!bg-sky-100 dark:bg-sky-900",
|
||||
PrettyTableColorGreen: "[&_tr:hover]:!bg-green-100 dark:bg-green-900",
|
||||
PrettyTableColorGray: "[&_tr:hover]:!bg-surface-strong",
|
||||
PrettyTableColorDarkBlue: "[&_tr:hover]:!bg-sky-100 dark:bg-sky-900",
|
||||
}
|
||||
|
||||
var ptHeaderPaddingCls = map[PrettyTableSize]string{
|
||||
@@ -127,13 +127,13 @@ type PrettyTableOptions struct {
|
||||
func prettyTableBodyClass(o PrettyTableOptions) string {
|
||||
c := ptBodyPaddingCls[o.Size]
|
||||
if o.BorderY {
|
||||
c = cx(c, "[&_td+td]:border-l [&_td+td]:border-neutral-300")
|
||||
c = cx(c, "[&_td+td]:border-l [&_td+td]:border-line-strong")
|
||||
}
|
||||
if o.Alternate {
|
||||
c = cx(c, "[&_tr:nth-child(even)]:bg-neutral-100")
|
||||
c = cx(c, "[&_tr:nth-child(even)]:bg-surface-raised")
|
||||
}
|
||||
if o.BorderX {
|
||||
c = cx(c, "[&_tr:not(:last-child)]:border-b [&_tr:not(:last-child)]:border-neutral-300")
|
||||
c = cx(c, "[&_tr:not(:last-child)]:border-b [&_tr:not(:last-child)]:border-line-strong")
|
||||
}
|
||||
if o.Hover {
|
||||
c = cx(c, ptRowHoverCls[o.Color])
|
||||
@@ -146,7 +146,7 @@ func prettyTableBodyClass(o PrettyTableOptions) string {
|
||||
func PrettyTable(columns []PrettyTableColumn, opts PrettyTableOptions, children ...*vdom.VNode) *vdom.VNode {
|
||||
containerCls := ptTblContainer
|
||||
if opts.SurroundingBorder {
|
||||
containerCls = cx(containerCls, "border border-neutral-300")
|
||||
containerCls = cx(containerCls, "border border-line-strong")
|
||||
}
|
||||
if opts.Shadow {
|
||||
containerCls = cx(containerCls, "shadow-sm")
|
||||
@@ -178,7 +178,7 @@ func PrettyTable(columns []PrettyTableColumn, opts PrettyTableOptions, children
|
||||
))
|
||||
}
|
||||
|
||||
thead := vdom.Thead(vdom.Attr("class", "[&_th]:border-b [&_th]:border-neutral-300"),
|
||||
thead := vdom.Thead(vdom.Attr("class", "[&_th]:border-b [&_th]:border-line-strong"),
|
||||
vdom.Tr(kids(nil, headerCells)...),
|
||||
)
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ func (f *RemoteFlash) Fire() { f.visible.Set(true) }
|
||||
// Clear hides the flash.
|
||||
func (f *RemoteFlash) Clear() { f.visible.Set(false) }
|
||||
|
||||
const remoteUpdateFlashCls = "remote-update-flash inline-flex items-center gap-1 rounded-full bg-emerald-100 border border-emerald-300 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-widest text-emerald-700"
|
||||
const remoteUpdateFlashCls = "remote-update-flash inline-flex items-center gap-1 rounded-full bg-emerald-100 dark:bg-emerald-900 border border-emerald-300 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-widest text-emerald-700 dark:text-emerald-400"
|
||||
|
||||
// RemoteUpdateFlash is a small pill that briefly shows "Updated". It renders
|
||||
// nothing (nil) when when is false, mirroring the TSX <Show when=…>.
|
||||
|
||||
@@ -4,10 +4,10 @@ package webui
|
||||
|
||||
import "kjol/vdom"
|
||||
|
||||
const sidebarNavRoot = "bg-white rounded-default shadow-sm border border-neutral-200 py-2"
|
||||
const sidebarNavRoot = "bg-surface rounded-default shadow-sm border border-line py-2"
|
||||
const sidebarNavList = "list-none m-0 p-0"
|
||||
const sidebarNavBtn = "w-full text-left py-2 pr-3 pl-4 text-sm cursor-pointer bg-transparent text-neutral-600 border-0 hover:text-neutral-900 hover:bg-neutral-50"
|
||||
const sidebarNavSubBtn = "w-full text-left py-1.5 pr-3 pl-8 text-xs cursor-pointer bg-transparent text-neutral-500 border-0 hover:text-neutral-900 hover:bg-neutral-50"
|
||||
const sidebarNavBtn = "w-full text-left py-2 pr-3 pl-4 text-sm cursor-pointer bg-transparent text-ink-soft border-0 hover:text-ink hover:bg-surface-muted"
|
||||
const sidebarNavSubBtn = "w-full text-left py-1.5 pr-3 pl-8 text-xs cursor-pointer bg-transparent text-ink-muted border-0 hover:text-ink hover:bg-surface-muted"
|
||||
const sidebarNavIcon = "mr-2"
|
||||
|
||||
const sidebarLayoutRoot = "grid grid-cols-1 gap-8 min-h-screen items-start lg:grid-cols-12"
|
||||
|
||||
265
go/webui/signaturepad.go
Normal file
265
go/webui/signaturepad.go
Normal file
@@ -0,0 +1,265 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"kjol/vdom"
|
||||
"kjol/wasmruntime"
|
||||
)
|
||||
|
||||
// Port of FormSignaturePad (web/uikit/Forms.tsx) — freehand signing.
|
||||
//
|
||||
// Two deliberate departures from the TSX.
|
||||
//
|
||||
// It draws into an SVG, not a <canvas>. The component's OUTPUT is SVG — the TSX kept
|
||||
// strokes in memory, painted them onto a canvas for the user, and serialized a separate
|
||||
// SVG string for the caller. That is two renderers for one drawing, and they can
|
||||
// disagree. Here the SVG the user is looking at IS the value the caller gets, so it
|
||||
// cannot be wrong. It also means a stored signature renders on the SERVER: the same
|
||||
// markup, no client needed to see it.
|
||||
//
|
||||
// And the in-flight stroke never touches a signal. A pointer moves sixty times a second,
|
||||
// and a signal write re-renders the whole page; the live stroke is written straight at
|
||||
// the element with SetHTML, and only the FINISHED stroke is committed. Without that,
|
||||
// signing your name would re-render the document a few hundred times.
|
||||
//
|
||||
// Create it once, alongside your signals — never inside a render.
|
||||
type SignaturePad struct {
|
||||
svgRef *vdom.Ref
|
||||
|
||||
// strokes are the finished ones: committed, re-rendered, part of the value.
|
||||
strokes *vdom.Signal[[]sigStroke]
|
||||
|
||||
// live is the stroke being drawn right now. NOT a signal — see above.
|
||||
live sigStroke
|
||||
drawing bool
|
||||
unsubs []wasmruntime.Unsub
|
||||
onChange func(svg string)
|
||||
width float64
|
||||
height float64
|
||||
}
|
||||
|
||||
type sigPoint struct{ X, Y float64 }
|
||||
type sigStroke []sigPoint
|
||||
|
||||
// SignaturePadOptions configures NewSignaturePad.
|
||||
type SignaturePadOptions struct {
|
||||
// Width and Height are the SVG's coordinate space (its viewBox), not its size on
|
||||
// screen — the element scales to its container and the strokes scale with it.
|
||||
// Default 600x120.
|
||||
Width, Height float64
|
||||
|
||||
// OnChange receives the signature as an SVG document, or "" when it is cleared.
|
||||
OnChange func(svg string)
|
||||
}
|
||||
|
||||
// NewSignaturePad creates a signature pad.
|
||||
func NewSignaturePad(o SignaturePadOptions) *SignaturePad {
|
||||
if o.Width <= 0 {
|
||||
o.Width = 600
|
||||
}
|
||||
if o.Height <= 0 {
|
||||
o.Height = 120
|
||||
}
|
||||
return &SignaturePad{
|
||||
svgRef: vdom.NewRef(),
|
||||
strokes: vdom.NewSignal([]sigStroke{}),
|
||||
onChange: o.OnChange,
|
||||
width: o.Width,
|
||||
height: o.Height,
|
||||
}
|
||||
}
|
||||
|
||||
// SignaturePadProps configures a render.
|
||||
type SignaturePadProps struct {
|
||||
Class string
|
||||
Hint string // shown while empty; default "Sign above"
|
||||
ClearText string // default "Clear"
|
||||
Disabled bool
|
||||
HideFooter bool // no hint / Clear button — for showing a signature back, read-only
|
||||
}
|
||||
|
||||
// IsEmpty reports whether anything has been drawn.
|
||||
func (s *SignaturePad) IsEmpty() bool { return len(s.strokes.Get()) == 0 }
|
||||
|
||||
// SVG is the signature as a standalone SVG document — the value to store. It is "" when
|
||||
// the pad is empty, so an empty pad is an empty string rather than a blank drawing.
|
||||
func (s *SignaturePad) SVG() string {
|
||||
strokes := s.strokes.Get()
|
||||
if len(strokes) == 0 {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 `)
|
||||
b.WriteString(sigNum(s.width) + " " + sigNum(s.height))
|
||||
b.WriteString(`" width="` + sigNum(s.width) + `" height="` + sigNum(s.height) + `">`)
|
||||
b.WriteString(sigPaths(strokes))
|
||||
b.WriteString(`</svg>`)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// Clear empties the pad.
|
||||
func (s *SignaturePad) Clear() {
|
||||
s.endDrag()
|
||||
s.live = nil
|
||||
s.strokes.Set([]sigStroke{})
|
||||
if s.onChange != nil {
|
||||
s.onChange("")
|
||||
}
|
||||
}
|
||||
|
||||
// Dispose removes any listeners left behind by an interrupted drag.
|
||||
func (s *SignaturePad) Dispose() { s.endDrag() }
|
||||
|
||||
// Render draws the pad.
|
||||
func (s *SignaturePad) Render(p SignaturePadProps) *vdom.VNode {
|
||||
svgMods := []vdom.Mod{
|
||||
vdom.WithRef(s.svgRef),
|
||||
vdom.Attr("viewBox", "0 0 "+sigNum(s.width)+" "+sigNum(s.height)),
|
||||
// touch-none: without it, drawing on a phone scrolls the page instead.
|
||||
vdom.Attr("class", "block h-auto w-full cursor-crosshair touch-none"),
|
||||
vdom.Attr("role", "img"),
|
||||
vdom.Raw(sigPaths(s.strokes.Get())),
|
||||
}
|
||||
if !p.Disabled {
|
||||
svgMods = append(svgMods, vdom.OnEvent(vdom.EVENT_POINTERDOWN, s.onDown))
|
||||
}
|
||||
|
||||
kids := []*vdom.VNode{vdom.Svg(svgMods...)}
|
||||
|
||||
if !p.HideFooter {
|
||||
hint := ""
|
||||
if s.IsEmpty() {
|
||||
hint = pick(p.Hint, "Sign above")
|
||||
}
|
||||
clear := []vdom.Mod{
|
||||
vdom.Attr("type", "button"),
|
||||
vdom.Attr("class", "text-xs text-ink-muted hover:text-ink disabled:opacity-50"),
|
||||
vdom.Text(pick(p.ClearText, "Clear")),
|
||||
}
|
||||
if p.Disabled || s.IsEmpty() {
|
||||
clear = append(clear, vdom.Attr("disabled", "disabled"))
|
||||
} else {
|
||||
clear = append(clear, vdom.On(vdom.EVENT_CLICK, s.Clear))
|
||||
}
|
||||
kids = append(kids, vdom.Div(
|
||||
vdom.Attr("class", "flex items-center justify-between border-t border-line bg-surface-muted px-2 py-1"),
|
||||
vdom.Span(vdom.Attr("class", "text-xs italic text-ink-faint"), vdom.Text(hint)),
|
||||
vdom.Button(clear...),
|
||||
))
|
||||
}
|
||||
|
||||
mods := []vdom.Mod{vdom.Attr("class", cx("overflow-hidden rounded-default border border-line-strong bg-surface", p.Class))}
|
||||
for _, k := range kids {
|
||||
mods = append(mods, k)
|
||||
}
|
||||
return vdom.Div(mods...)
|
||||
}
|
||||
|
||||
// ---- drawing ------------------------------------------------------------
|
||||
|
||||
func (s *SignaturePad) onDown(e vdom.Event) {
|
||||
e.PreventDefault()
|
||||
s.endDrag() // a previous drag that never got its pointerup (alt-tab, say)
|
||||
|
||||
s.drawing = true
|
||||
s.live = sigStroke{s.point(e)}
|
||||
|
||||
// The listeners go on the DOCUMENT, not the element. Drag off the edge of the pad
|
||||
// and the stroke should follow the cursor and finish when you let go — with element
|
||||
// listeners the pointer simply escapes and the stroke is left half-drawn.
|
||||
s.unsubs = append(s.unsubs,
|
||||
wasmruntime.OnDocument(vdom.EVENT_POINTERMOVE, false, s.onMove),
|
||||
wasmruntime.OnDocument(vdom.EVENT_POINTERUP, false, s.onUp),
|
||||
wasmruntime.OnDocument(vdom.EVENT_POINTERCANCEL, false, s.onUp),
|
||||
)
|
||||
}
|
||||
|
||||
func (s *SignaturePad) onMove(e vdom.Event) {
|
||||
if !s.drawing {
|
||||
return
|
||||
}
|
||||
s.live = append(s.live, s.point(e))
|
||||
|
||||
// Imperative. This runs on every pointer move; a signal here would re-render the
|
||||
// entire application between one pixel of ink and the next.
|
||||
wasmruntime.SetHTML(s.svgRef, sigPaths(append(append([]sigStroke{}, s.strokes.Get()...), s.live)))
|
||||
}
|
||||
|
||||
func (s *SignaturePad) onUp(vdom.Event) {
|
||||
if !s.drawing {
|
||||
return
|
||||
}
|
||||
s.endDrag()
|
||||
|
||||
// A stroke of one point is a click, not a mark. Dropping it keeps a stray tap from
|
||||
// counting as a signature.
|
||||
if len(s.live) >= 2 {
|
||||
s.strokes.Set(append(append([]sigStroke{}, s.strokes.Get()...), s.live))
|
||||
if s.onChange != nil {
|
||||
s.onChange(s.SVG())
|
||||
}
|
||||
}
|
||||
s.live = nil
|
||||
}
|
||||
|
||||
func (s *SignaturePad) endDrag() {
|
||||
s.drawing = false
|
||||
for _, u := range s.unsubs {
|
||||
u()
|
||||
}
|
||||
s.unsubs = nil
|
||||
}
|
||||
|
||||
// point maps a pointer's viewport coordinates into the SVG's coordinate space. The
|
||||
// element is fluid and the viewBox is fixed, so the two differ by whatever the browser
|
||||
// scaled the SVG to — measuring is the only way to know.
|
||||
func (s *SignaturePad) point(e vdom.Event) sigPoint {
|
||||
r := wasmruntime.Measure(s.svgRef)
|
||||
if r.Width == 0 || r.Height == 0 {
|
||||
return sigPoint{}
|
||||
}
|
||||
return sigPoint{
|
||||
X: (float64(e.ClientX()) - r.X) * (s.width / r.Width),
|
||||
Y: (float64(e.ClientY()) - r.Y) * (s.height / r.Height),
|
||||
}
|
||||
}
|
||||
|
||||
// ---- serialization ------------------------------------------------------
|
||||
|
||||
// sigPaths renders strokes as SVG <path> elements, smoothed.
|
||||
//
|
||||
// Straight lines between raw pointer samples look like a seismograph, not handwriting.
|
||||
// Each segment is a quadratic curve THROUGH the sampled point and ending at the midpoint
|
||||
// of the next one, which is the standard trick for turning a polyline into something
|
||||
// that reads as a pen stroke.
|
||||
func sigPaths(strokes []sigStroke) string {
|
||||
var b strings.Builder
|
||||
for _, st := range strokes {
|
||||
if len(st) < 2 {
|
||||
continue
|
||||
}
|
||||
b.WriteString(`<path d="M`)
|
||||
b.WriteString(sigNum(st[0].X) + "," + sigNum(st[0].Y))
|
||||
if len(st) == 2 {
|
||||
b.WriteString(" L" + sigNum(st[1].X) + "," + sigNum(st[1].Y))
|
||||
} else {
|
||||
for i := 1; i < len(st)-1; i++ {
|
||||
mx := (st[i].X + st[i+1].X) / 2
|
||||
my := (st[i].Y + st[i+1].Y) / 2
|
||||
b.WriteString(" Q" + sigNum(st[i].X) + "," + sigNum(st[i].Y) + "," + sigNum(mx) + "," + sigNum(my))
|
||||
}
|
||||
last := st[len(st)-1]
|
||||
b.WriteString(" L" + sigNum(last.X) + "," + sigNum(last.Y))
|
||||
}
|
||||
b.WriteString(`" fill="none" stroke="#1a1a2e" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>`)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// sigNum formats a coordinate to one decimal, without a trailing ".0" — the markup is
|
||||
// the value the caller stores, and there is no reason to store 600.0 as six bytes.
|
||||
func sigNum(f float64) string {
|
||||
return strconv.FormatFloat(f, 'f', -1, 64)
|
||||
}
|
||||
88
go/webui/signaturepad_test.go
Normal file
88
go/webui/signaturepad_test.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"kjol/vdom"
|
||||
)
|
||||
|
||||
// The pad's value IS the markup it shows — that is the whole reason it draws into an SVG
|
||||
// rather than a canvas. If the two could differ, the user would sign one thing and the
|
||||
// caller would store another.
|
||||
func TestSignaturePadValueIsWhatIsDrawn(t *testing.T) {
|
||||
var got string
|
||||
pad := NewSignaturePad(SignaturePadOptions{OnChange: func(svg string) { got = svg }})
|
||||
|
||||
pad.strokes.Set([]sigStroke{{{X: 10, Y: 20}, {X: 30, Y: 40}, {X: 50, Y: 20}}})
|
||||
|
||||
value := pad.SVG()
|
||||
shown := vdom.RenderHTML(pad.Render(SignaturePadProps{}))
|
||||
|
||||
// Every path in the value is present in the rendered element.
|
||||
paths := sigPaths(pad.strokes.Get())
|
||||
if paths == "" {
|
||||
t.Fatal("no path was generated for a three-point stroke")
|
||||
}
|
||||
if !strings.Contains(value, paths) {
|
||||
t.Error("the emitted SVG does not contain the strokes it was built from")
|
||||
}
|
||||
if !strings.Contains(shown, `d="M10,20`) {
|
||||
t.Errorf("the rendered pad is not showing the stroke:\n%s", shown)
|
||||
}
|
||||
_ = got
|
||||
}
|
||||
|
||||
// An empty pad is an empty string, not a blank drawing. A caller storing the value must
|
||||
// be able to ask "did they sign?" without parsing SVG.
|
||||
func TestSignaturePadEmptyIsEmptyString(t *testing.T) {
|
||||
pad := NewSignaturePad(SignaturePadOptions{})
|
||||
if !pad.IsEmpty() {
|
||||
t.Error("a fresh pad should be empty")
|
||||
}
|
||||
if pad.SVG() != "" {
|
||||
t.Errorf("an empty pad produced %q, want \"\"", pad.SVG())
|
||||
}
|
||||
}
|
||||
|
||||
// Clear reports the emptiness to the caller. Without the callback a cleared pad would
|
||||
// leave the last signature sitting in whatever the caller stored it in.
|
||||
func TestSignaturePadClearNotifies(t *testing.T) {
|
||||
got := "not called"
|
||||
pad := NewSignaturePad(SignaturePadOptions{OnChange: func(svg string) { got = svg }})
|
||||
pad.strokes.Set([]sigStroke{{{X: 1, Y: 1}, {X: 2, Y: 2}}})
|
||||
|
||||
pad.Clear()
|
||||
|
||||
if got != "" {
|
||||
t.Errorf("OnChange got %q on clear, want \"\"", got)
|
||||
}
|
||||
if !pad.IsEmpty() {
|
||||
t.Error("the pad is not empty after Clear")
|
||||
}
|
||||
}
|
||||
|
||||
// A single point is a tap, not a mark: it has no length, produces no path, and must not
|
||||
// count as a signature.
|
||||
func TestSignaturePadIgnoresSinglePointStrokes(t *testing.T) {
|
||||
if got := sigPaths([]sigStroke{{{X: 5, Y: 5}}}); got != "" {
|
||||
t.Errorf("a one-point stroke produced a path: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Two points are a straight line; three or more are smoothed into curves. Handwriting
|
||||
// drawn as raw polylines looks like a seismograph.
|
||||
func TestSignaturePadSmoothsLongStrokes(t *testing.T) {
|
||||
line := sigPaths([]sigStroke{{{X: 0, Y: 0}, {X: 10, Y: 10}}})
|
||||
if !strings.Contains(line, " L10,10") {
|
||||
t.Errorf("a two-point stroke should be a straight line: %q", line)
|
||||
}
|
||||
if strings.Contains(line, "Q") {
|
||||
t.Errorf("a two-point stroke has nothing to smooth: %q", line)
|
||||
}
|
||||
|
||||
curve := sigPaths([]sigStroke{{{X: 0, Y: 0}, {X: 10, Y: 10}, {X: 20, Y: 0}}})
|
||||
if !strings.Contains(curve, "Q") {
|
||||
t.Errorf("a three-point stroke should be smoothed: %q", curve)
|
||||
}
|
||||
}
|
||||
@@ -17,8 +17,8 @@ import (
|
||||
// defaultIndex prop likewise collapses into the caller-supplied ActiveIndex.
|
||||
|
||||
const tabsBase = "flex items-center gap-1.5 cursor-pointer py-2 px-4 text-sm font-medium bg-transparent border-0 border-b-2 transition-[color,border-color] duration-150"
|
||||
const tabsInactive = "text-text-muted border-neutral-200 hover:text-text-body"
|
||||
const tabsActive = "text-primary border-primary"
|
||||
const tabsInactive = "text-text-muted border-line hover:text-text-body"
|
||||
const tabsActive = "text-accent border-primary"
|
||||
|
||||
// TabItem is one tab: a title, an optional numeric badge (shown only when > 0),
|
||||
// and optional inline panel content.
|
||||
@@ -119,7 +119,7 @@ func TabGroup(p TabGroupProps) *vdom.VNode {
|
||||
header = append(header, vdom.Button(btn...))
|
||||
}
|
||||
if p.Actions != nil {
|
||||
header = append(header, vdom.Div(vdom.Attr("class", "tab-actions flex-1 self-end border-b-2 border-neutral-200 flex items-center justify-end pb-1"),
|
||||
header = append(header, vdom.Div(vdom.Attr("class", "tab-actions flex-1 self-end border-b-2 border-line flex items-center justify-end pb-1"),
|
||||
vdom.Div(vdom.Attr("class", "flex items-center min-w-0"), p.Actions),
|
||||
))
|
||||
}
|
||||
|
||||
207
go/webui/theme.go
Normal file
207
go/webui/theme.go
Normal file
@@ -0,0 +1,207 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"kjol/vdom"
|
||||
"kjol/wasmruntime"
|
||||
)
|
||||
|
||||
// Dark mode.
|
||||
//
|
||||
// The kit is themed by TOKENS, not by a dark: variant on every class. A component says
|
||||
// bg-surface, text-ink, border-line; the theme decides what those mean. Flipping the
|
||||
// theme is therefore one class on <html> and a block of CSS variables — not four hundred
|
||||
// class strings, each of which is a chance to forget one and leave a white card sitting
|
||||
// in the middle of a dark page.
|
||||
//
|
||||
// Only genuinely COLOURED things (an alert's red tint, a validation message) carry dark:
|
||||
// variants, because those are not surfaces or ink and there is no token that means "red,
|
||||
// but for a dark background".
|
||||
//
|
||||
// The app must define the tokens and the variant in its stylesheet. See the example's
|
||||
// css/app.css; the required set is listed in ThemeTokens below.
|
||||
|
||||
// ThemeTokens is the contract between the kit and an app's stylesheet: the CSS custom
|
||||
// properties every component assumes exist, with light values in :root and dark values
|
||||
// under .dark.
|
||||
//
|
||||
// It is a documentation constant, not code — but it is here, next to the components that
|
||||
// depend on it, rather than in a README nobody opens when a card comes out the wrong
|
||||
// colour.
|
||||
const ThemeTokens = `
|
||||
--color-surface page and card backgrounds
|
||||
--color-surface-muted a subtle fill (table stripes, footers)
|
||||
--color-surface-raised a hovered or filled row
|
||||
--color-surface-strong the strongest neutral fill
|
||||
--color-line ordinary borders and dividers
|
||||
--color-line-strong an input's border — the one you must be able to see
|
||||
--color-ink body text and headings
|
||||
--color-ink-soft secondary text
|
||||
--color-ink-muted labels, captions
|
||||
--color-ink-faint placeholders, disabled text
|
||||
--color-accent accent TEXT and icons (readable on the surface)
|
||||
--color-primary accent FILLS, which carry white text
|
||||
--color-primary-hover
|
||||
--color-primary-subtle a tinted panel
|
||||
--color-primary-border
|
||||
`
|
||||
|
||||
// ThemeMode is what the user chose. System is the default: follow the OS until told
|
||||
// otherwise, because a site that ignores the OS setting is a site that flashes white at
|
||||
// someone who asked their whole computer not to.
|
||||
type ThemeMode string
|
||||
|
||||
const (
|
||||
ThemeSystem ThemeMode = "system"
|
||||
ThemeLight ThemeMode = "light"
|
||||
ThemeDark ThemeMode = "dark"
|
||||
)
|
||||
|
||||
// themeStorageKey is where the choice is kept. The bootstrap script in the document head
|
||||
// reads the SAME key — see ThemeBootScript.
|
||||
const themeStorageKey = "kjol-theme"
|
||||
|
||||
// Theme is the site-wide theme controller. Create one, call Init once after mount, and
|
||||
// render its Toggle wherever the switch belongs.
|
||||
type Theme struct {
|
||||
mode *vdom.Signal[ThemeMode]
|
||||
// dark is the RESOLVED answer: what "system" actually means right now.
|
||||
dark *vdom.Signal[bool]
|
||||
stop wasmruntime.Unsub
|
||||
}
|
||||
|
||||
// NewTheme creates the controller. It reads no storage and touches no DOM — Init does
|
||||
// that, once the document exists.
|
||||
func NewTheme() *Theme {
|
||||
return &Theme{
|
||||
mode: vdom.NewSignal(ThemeSystem),
|
||||
dark: vdom.NewSignal(false),
|
||||
}
|
||||
}
|
||||
|
||||
// Init adopts the stored choice and starts following the OS while the mode is "system".
|
||||
// Call it once, after the app has mounted.
|
||||
//
|
||||
// It does not FLASH, because it is not what puts the class on <html> for the first
|
||||
// paint: ThemeBootScript already did that, before any of this code existed. Init only
|
||||
// takes over.
|
||||
func (t *Theme) Init() {
|
||||
if s, ok := wasmruntime.StorageGet(themeStorageKey); ok {
|
||||
switch ThemeMode(s) {
|
||||
case ThemeLight:
|
||||
t.mode.Set(ThemeLight)
|
||||
case ThemeDark:
|
||||
t.mode.Set(ThemeDark)
|
||||
}
|
||||
}
|
||||
t.apply()
|
||||
|
||||
// Follow the OS, but only while the user has not overridden it.
|
||||
t.stop = wasmruntime.OnMediaChange("(prefers-color-scheme: dark)", func(bool) {
|
||||
if t.mode.Get() == ThemeSystem {
|
||||
t.apply()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Dispose stops following the OS.
|
||||
func (t *Theme) Dispose() {
|
||||
if t.stop != nil {
|
||||
t.stop()
|
||||
}
|
||||
}
|
||||
|
||||
// Mode is the user's choice; IsDark is what that currently resolves to.
|
||||
func (t *Theme) Mode() ThemeMode { return t.mode.Get() }
|
||||
func (t *Theme) IsDark() bool { return t.dark.Get() }
|
||||
|
||||
// Set changes the theme. "system" forgets the choice entirely rather than storing the
|
||||
// current resolution — otherwise "follow my OS" would quietly freeze at whatever the OS
|
||||
// happened to say the day you chose it.
|
||||
func (t *Theme) Set(m ThemeMode) {
|
||||
t.mode.Set(m)
|
||||
if m == ThemeSystem {
|
||||
wasmruntime.StorageRemove(themeStorageKey)
|
||||
} else {
|
||||
wasmruntime.StorageSet(themeStorageKey, string(m))
|
||||
}
|
||||
t.apply()
|
||||
}
|
||||
|
||||
// Toggle flips between light and dark. It resolves "system" first, so the first click
|
||||
// does what it looks like it will do — the opposite of what you are looking at.
|
||||
func (t *Theme) Toggle() {
|
||||
if t.resolve() {
|
||||
t.Set(ThemeLight)
|
||||
return
|
||||
}
|
||||
t.Set(ThemeDark)
|
||||
}
|
||||
|
||||
// resolve turns the mode into a yes or no.
|
||||
func (t *Theme) resolve() bool {
|
||||
switch t.mode.Get() {
|
||||
case ThemeDark:
|
||||
return true
|
||||
case ThemeLight:
|
||||
return false
|
||||
default:
|
||||
return wasmruntime.PrefersDark()
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Theme) apply() {
|
||||
dark := t.resolve()
|
||||
t.dark.Set(dark)
|
||||
wasmruntime.SetRootClass("dark", dark)
|
||||
}
|
||||
|
||||
// ThemeToggleProps configures the switch.
|
||||
type ThemeToggleProps struct {
|
||||
Class string
|
||||
// Small renders an icon-only button.
|
||||
Small bool
|
||||
}
|
||||
|
||||
// ThemeToggle is the switch: one button, showing the theme you would get by pressing it.
|
||||
func (t *Theme) ThemeToggle(p ThemeToggleProps) *vdom.VNode {
|
||||
icon, label := "moon", "Dark"
|
||||
if t.IsDark() {
|
||||
icon, label = "sun", "Light"
|
||||
}
|
||||
|
||||
cls := "inline-flex items-center gap-2 rounded-default border border-line px-2.5 py-1.5 text-sm text-ink-soft hover:bg-surface-raised hover:text-ink"
|
||||
if p.Small {
|
||||
cls = "inline-flex h-8 w-8 items-center justify-center rounded-default border border-line text-ink-soft hover:bg-surface-raised hover:text-ink"
|
||||
}
|
||||
|
||||
mods := []vdom.Mod{
|
||||
vdom.Attr("type", "button"),
|
||||
vdom.Attr("class", cx(cls, p.Class)),
|
||||
vdom.Attr("aria-label", "Switch to "+label+" theme"),
|
||||
vdom.Attr("title", "Switch to "+label+" theme"),
|
||||
vdom.On(vdom.EVENT_CLICK, t.Toggle),
|
||||
IconInline(icon, 15, ""),
|
||||
}
|
||||
if !p.Small {
|
||||
mods = append(mods, vdom.Span(vdom.Text(label)))
|
||||
}
|
||||
return vdom.Button(mods...)
|
||||
}
|
||||
|
||||
// ThemeBootScript is the inline script an app puts in its <head>, BEFORE any stylesheet
|
||||
// or markup.
|
||||
//
|
||||
// It exists to prevent the flash. The server cannot read localStorage, so it cannot know
|
||||
// which theme to render; if the class were applied by the WebAssembly after it loads,
|
||||
// every dark-mode user would be shown a white page for as long as the binary takes to
|
||||
// download, and then have it yanked out from under them. This runs first, synchronously,
|
||||
// and the first paint is already correct.
|
||||
//
|
||||
// It is ten lines of JavaScript in a project that has none. That is the price of the
|
||||
// browser giving the page no way to ask about localStorage before it paints, and it is
|
||||
// worth paying — the alternative is a white flash on every single load.
|
||||
const ThemeBootScript = `<script>(function(){try{
|
||||
var m = localStorage.getItem("kjol-theme");
|
||||
var dark = m === "dark" || (!m && matchMedia("(prefers-color-scheme: dark)").matches);
|
||||
if (dark) document.documentElement.classList.add("dark");
|
||||
}catch(e){}})();</script>`
|
||||
@@ -72,7 +72,7 @@ var toastContainerPositions = map[ToastPosition]string{
|
||||
ToastBottomCenter: "bottom-4 left-1/2 -translate-x-1/2 flex-col-reverse",
|
||||
}
|
||||
|
||||
const toastBase = "relative overflow-hidden rounded-default shadow-lg border border-neutral-200 border-l-4 bg-white min-w-72 max-w-md transition-[opacity,transform] duration-150 ease-out"
|
||||
const toastBase = "relative overflow-hidden rounded-default shadow-lg border border-line border-l-4 bg-surface min-w-72 max-w-md transition-[opacity,transform] duration-150 ease-out"
|
||||
|
||||
var toastTypeBorder = map[ToastType]string{
|
||||
ToastSuccess: "border-l-green-700",
|
||||
@@ -84,9 +84,9 @@ var toastTypeBorder = map[ToastType]string{
|
||||
|
||||
var toastIconColor = map[ToastType]string{
|
||||
ToastSuccess: "text-green-600",
|
||||
ToastError: "text-red-600",
|
||||
ToastError: "text-red-600 dark:text-red-400",
|
||||
ToastWarning: "text-yellow-600",
|
||||
ToastInfo: "text-sky-700",
|
||||
ToastInfo: "text-sky-700 dark:text-sky-400",
|
||||
ToastGeneric: "",
|
||||
}
|
||||
|
||||
@@ -108,10 +108,10 @@ func ToastItem(t Toast, onDismiss func(string), progressRef *vdom.Ref) *vdom.VNo
|
||||
if icon != "" {
|
||||
row = append(row, Icon(icon, 20, cx("shrink-0 mt-0.5", toastIconColor[typ])))
|
||||
}
|
||||
row = append(row, vdom.Div(vdom.Attr("class", "flex-1 text-sm text-neutral-800"), vdom.Text(t.Message)))
|
||||
row = append(row, vdom.Div(vdom.Attr("class", "flex-1 text-sm text-ink"), vdom.Text(t.Message)))
|
||||
if t.Dismissible {
|
||||
dismiss := []vdom.Mod{
|
||||
vdom.Attr("class", "shrink-0 cursor-pointer text-neutral-400 hover:text-neutral-600 bg-transparent border-0 p-0 transition-colors"),
|
||||
vdom.Attr("class", "shrink-0 cursor-pointer text-ink-faint hover:text-ink-soft bg-transparent border-0 p-0 transition-colors"),
|
||||
vdom.Attr("aria-label", "Dismiss"),
|
||||
}
|
||||
if onDismiss != nil {
|
||||
@@ -133,13 +133,13 @@ func ToastItem(t Toast, onDismiss func(string), progressRef *vdom.Ref) *vdom.VNo
|
||||
// string on every render is what stops the reconciler's attribute diff from
|
||||
// resetting it back to 100% mid-countdown.
|
||||
bar := []vdom.Mod{
|
||||
vdom.Attr("class", "h-full bg-neutral-300"),
|
||||
vdom.Attr("class", "h-full bg-surface-strong"),
|
||||
vdom.Attr("style", "width:100%"),
|
||||
}
|
||||
if progressRef != nil {
|
||||
bar = append(bar, vdom.WithRef(progressRef))
|
||||
}
|
||||
mods = append(mods, vdom.Div(vdom.Attr("class", "h-1 w-full bg-neutral-100"),
|
||||
mods = append(mods, vdom.Div(vdom.Attr("class", "h-1 w-full bg-surface-raised"),
|
||||
vdom.Div(bar...),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ func ToggleSwitch(checked bool, onChange func(bool), label, description string,
|
||||
onChange(!checked)
|
||||
}
|
||||
|
||||
trackState := "bg-neutral-300"
|
||||
trackState := "bg-surface-strong"
|
||||
if checked {
|
||||
trackState = "bg-primary"
|
||||
}
|
||||
@@ -24,7 +24,7 @@ func ToggleSwitch(checked bool, onChange func(bool), label, description string,
|
||||
if checked {
|
||||
knobState = "translate-x-[18px]"
|
||||
}
|
||||
knobCls := cx("inline-block h-4 w-4 transform rounded-full bg-white shadow-sm transition-transform", knobState)
|
||||
knobCls := cx("inline-block h-4 w-4 transform rounded-full bg-surface shadow-sm transition-transform", knobState)
|
||||
|
||||
ariaChecked := "false"
|
||||
if checked {
|
||||
@@ -47,16 +47,16 @@ func ToggleSwitch(checked bool, onChange func(bool), label, description string,
|
||||
if label != "" || description != "" {
|
||||
text := []vdom.Mod{vdom.Attr("class", "flex flex-col leading-tight")}
|
||||
if label != "" {
|
||||
labelColor := "text-neutral-800"
|
||||
labelColor := "text-ink"
|
||||
if disabled {
|
||||
labelColor = "text-neutral-400"
|
||||
labelColor = "text-ink-faint"
|
||||
}
|
||||
text = append(text, vdom.Span(vdom.Attr("class", cx("text-sm select-none", labelColor)),
|
||||
vdom.On(vdom.EVENT_CLICK, toggle),
|
||||
vdom.Text(label)))
|
||||
}
|
||||
if description != "" {
|
||||
text = append(text, vdom.Span(vdom.Attr("class", "text-xs text-neutral-500"), vdom.Text(description)))
|
||||
text = append(text, vdom.Span(vdom.Attr("class", "text-xs text-ink-muted"), vdom.Text(description)))
|
||||
}
|
||||
mods = append(mods, vdom.Div(text...))
|
||||
}
|
||||
|
||||
@@ -155,7 +155,7 @@ const (
|
||||
|
||||
// Tailwind classes, verbatim from the TSX.
|
||||
const (
|
||||
tutorialPopoverClass = "fixed z-200 bg-white rounded-default shadow-lg border border-neutral-200 max-w-sm"
|
||||
tutorialPopoverClass = "fixed z-200 bg-surface rounded-default shadow-lg border border-line max-w-sm"
|
||||
tutorialSpotlightClass = "fixed z-150 pointer-events-none rounded-default"
|
||||
tutorialCatcherClass = "fixed inset-0 -z-10 cursor-pointer"
|
||||
tutorialBackdropClass = "fixed inset-0 bg-black/50 z-150"
|
||||
@@ -701,23 +701,23 @@ func (t *Tutorial) content(step *TutorialStep, idx, total int) *vdom.VNode {
|
||||
// Header: optional title + "X of N", and a close button.
|
||||
headerLeft := []vdom.Mod{vdom.Attr("class", "flex items-center gap-2")}
|
||||
if step != nil && step.Title != "" {
|
||||
headerLeft = append(headerLeft, vdom.Span(vdom.Attr("class", "font-medium text-neutral-900"),
|
||||
headerLeft = append(headerLeft, vdom.Span(vdom.Attr("class", "font-medium text-ink"),
|
||||
vdom.Text(step.Title)))
|
||||
}
|
||||
headerLeft = append(headerLeft, vdom.Span(vdom.Attr("class", "text-xs text-neutral-500"),
|
||||
headerLeft = append(headerLeft, vdom.Span(vdom.Attr("class", "text-xs text-ink-muted"),
|
||||
vdom.Text(strconv.Itoa(idx+1)+" of "+strconv.Itoa(total))))
|
||||
|
||||
header := vdom.Div(vdom.Attr("class", "flex items-center justify-between p-4 pb-2"),
|
||||
vdom.Div(headerLeft...),
|
||||
vdom.Button(vdom.Attr("type", "button"),
|
||||
vdom.Attr("class", "cursor-pointer text-neutral-400 bg-transparent border-0 p-0 leading-none transition-colors hover:text-neutral-600"),
|
||||
vdom.Attr("class", "cursor-pointer text-ink-faint bg-transparent border-0 p-0 leading-none transition-colors hover:text-ink-soft"),
|
||||
vdom.On(vdom.EVENT_CLICK, t.End),
|
||||
Icon("xmark", 18, ""),
|
||||
),
|
||||
)
|
||||
|
||||
// Body.
|
||||
bodyMods := []vdom.Mod{vdom.Attr("class", "px-4 pb-4 text-sm text-neutral-700")}
|
||||
bodyMods := []vdom.Mod{vdom.Attr("class", "px-4 pb-4 text-sm text-ink-soft")}
|
||||
if step != nil && step.Content != nil {
|
||||
if c := step.Content(); c != nil {
|
||||
bodyMods = append(bodyMods, c)
|
||||
@@ -749,7 +749,7 @@ func (t *Tutorial) content(step *TutorialStep, idx, total int) *vdom.VNode {
|
||||
if total > 1 {
|
||||
dotMods := []vdom.Mod{vdom.Attr("class", "flex justify-center gap-1.5 pb-3")}
|
||||
for i := 0; i < total; i++ {
|
||||
state := "bg-neutral-300"
|
||||
state := "bg-surface-strong"
|
||||
if i == idx {
|
||||
state = "bg-sky-600 scale-110"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user