Merge branch 'master' of git.maxamundsen.dev:maxamundsen/kjol
This commit is contained in:
19
go/bundler/public_tailwind.go
Normal file
19
go/bundler/public_tailwind.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package bundler
|
||||
|
||||
// CompileTailwind compiles a Tailwind v4 entry stylesheet with kjol's native
|
||||
// engine, discovering utility candidates from the given source globs (relative
|
||||
// to baseDir). The scanner is language-agnostic — it extracts candidate class
|
||||
// tokens from any text file — so this works for markup authored in Go (e.g. the
|
||||
// go-wasm-web example's webui components) just as well as .tsx/.html.
|
||||
//
|
||||
// entryCSS is the stylesheet source (typically `@import "tailwindcss";` plus an
|
||||
// `@theme { … }` block). Returns minified CSS. This is the app-bundler engine
|
||||
// (twCompile/scanSources) exposed for consumers that don't go through Build.
|
||||
func CompileTailwind(entryCSS, baseDir string, sourceGlobs []string) (string, error) {
|
||||
candidates := scanSources(baseDir, sourceGlobs)
|
||||
compiled, _, err := twCompile(entryCSS, baseDir, candidates)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return m.String("text/css", compiled)
|
||||
}
|
||||
89
go/cmd/examples/go-wasm-web/README.md
Normal file
89
go/cmd/examples/go-wasm-web/README.md
Normal file
@@ -0,0 +1,89 @@
|
||||
# go-wasm-web — example app for the gowasm engine
|
||||
|
||||
A runnable example of kjol's **gowasm** engine: author UI **components in pure
|
||||
Go**, compiled to **WebAssembly**, with **SSR + hydration**, **Next.js-style
|
||||
server components**, layouts, the **`kjol/webui` component kit**, **Tailwind CSS**
|
||||
(compiled by kjol's own engine), and a **flash-free, state-preserving hot reload**.
|
||||
No custom markup, no JSX — just Go. The engine lives in top-level kjol packages
|
||||
(`kjol/go/{vdom,wasmruntime,rsc,wasmdevserver,webui}`); this directory is only the
|
||||
app that consumes them.
|
||||
|
||||
## Run it
|
||||
|
||||
```sh
|
||||
cd cmd/examples/go-wasm-web
|
||||
go run ./server # codegen + SSR + hot reload at http://localhost:8085
|
||||
```
|
||||
|
||||
Open http://localhost:8085. `/` and `/about` use the light **public** layout;
|
||||
`/chart`, `/server`, **`/data`** (client-side fetching), and **`/kit`** (a UI-kit
|
||||
"kitchen-sink" demo of the webui components) use the dark **app** layout. Edit
|
||||
any `.go` file and the browser hot-swaps the new wasm **without a full reload or
|
||||
a flash**, preserving page state; a build failure shows the Go compiler output
|
||||
as an overlay.
|
||||
|
||||
**Data fetching** (`/data`) shows both directions of `kjol/httputil`: the server
|
||||
answers `/api/quotes` with `httputil.RespondGob([]Quote)` and the client decodes
|
||||
it straight back into `[]Quote` with `httputil.FetchGob` (the same Go type on
|
||||
both ends — no JSON); and a **user-entered** GitHub repo (`owner/name`) is
|
||||
fetched with `httputil.FetchJSON` into a tagged Go struct. The client HTTP
|
||||
transport is `wasmruntime.FetchBytes`, injected once via
|
||||
`httputil.SetClientTransport`.
|
||||
|
||||
Styling is **Tailwind**: the dev server (and `build.sh`) run `kjol/cmd/twcss`,
|
||||
which scans the Go markup + the `webui` kit for utility classes and compiles
|
||||
`css/app.css` → `wwwroot/app.css` with kjol's native Tailwind v4 engine. There is
|
||||
**no Bootstrap and no hand-written CSS**. (`build.sh` does a one-off build.)
|
||||
|
||||
## This is a separate module
|
||||
|
||||
`go.mod` here declares its own module (`gowasmweb`) with `replace kjol => ../../..`,
|
||||
so the app's `go-chart` dependency (and freetype / x/image) stays out of kjol —
|
||||
the engine packages (`vdom`, `wasmruntime`, `rsc`, `wasmdevserver`) are
|
||||
**stdlib-only**. `go build ./...` at the kjol root does not descend into this
|
||||
nested module; build it from this directory.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
app/ the application — neutral, standalone functions (no central struct)
|
||||
pages.go Deps + Shell + App/Public layouts + nav + Counter + pages
|
||||
chart.go Chart page (go-chart, renders on both sides)
|
||||
kit.go /kit — UI-kit demo page showcasing kjol/webui components
|
||||
data.go /data — client fetch: gob from /api/quotes + third-party JSON
|
||||
server_counter.go //gowasm:server component (server-only; clicks-over-time chart)
|
||||
*.gen.go GENERATED by kjol/cmd/wasmgen (routes, layout dispatch, stubs)
|
||||
css/app.css Tailwind entry (@import "tailwindcss" + @theme tokens)
|
||||
wasm/ the js/wasm client entry point (main_native.go is a host stub)
|
||||
server/ the dev-server main: injects Build/Render/Document into wasmdevserver
|
||||
wwwroot/ wasmboot.js (+ generated app.css, wasm_exec.js, app.wasm)
|
||||
```
|
||||
|
||||
## How it maps onto the engine (top-level `kjol` packages)
|
||||
|
||||
| Engine package | Role | This app's use |
|
||||
|---|---|---|
|
||||
| `kjol/vdom` | neutral virtual DOM (native + wasm): `VNode`, builders, `Signal`, `RenderHTML` | pages build `*VNode`; `server` SSRs with `vdom.RenderHTML` |
|
||||
| `kjol/wasmruntime` | wasm client runtime: reconcile, `Run`/`Hydrate`, router, fetch, HMR state | `wasm/main.go` calls `Hydrate`/`Run` |
|
||||
| `kjol/rsc` | stateless server components over HTTP (gob) | `//gowasm:server` + the generated client stub |
|
||||
| `kjol/wasmdevserver` | reusable dev server: SSR, `/rsc`, hot reload, error overlay | `server/main.go` fills a `wasmdevserver.Config` |
|
||||
| `kjol/httputil` | gob/JSON responders + typed client fetch (`RespondGob`, `FetchGob`, `FetchJSON`) | the `/data` page + the `/api/quotes` handler |
|
||||
| `kjol/cmd/wasmgen` | directive codegen → `app/*.gen.go` | run by `buildWasm` and `//go:generate` |
|
||||
|
||||
The **golden rule** holds: `wasmdevserver` imports no app code. The app injects
|
||||
`Build` (how to compile the wasm), `Render` (SSR a route → HTML), and `Document`
|
||||
(wrap it in a page) via `wasmdevserver.Config` — the same coupling inversion kjol
|
||||
uses elsewhere.
|
||||
|
||||
## Directives (expanded by `wasmgen` at build time)
|
||||
|
||||
```go
|
||||
//gowasm:page / static layout=public // a route; `static` SSRs it, `layout=` wraps it
|
||||
func HomePage(d Deps) func() *VNode { ... }
|
||||
|
||||
//gowasm:layout public // chrome for pages that opt into layout=public
|
||||
func PublicLayout(d Deps, content *VNode) *VNode { ... }
|
||||
|
||||
//gowasm:server // runs on the server; calling it looks identical
|
||||
func ServerCounter() func() *VNode { count := NewSignal(0); ... }
|
||||
```
|
||||
81
go/cmd/examples/go-wasm-web/app/chart.go
Normal file
81
go/cmd/examples/go-wasm-web/app/chart.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"math/rand"
|
||||
|
||||
chart "github.com/wcharczuk/go-chart/v2"
|
||||
|
||||
. "kjol/vdom"
|
||||
ui "kjol/webui"
|
||||
)
|
||||
|
||||
var chartLabels = []string{"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"}
|
||||
|
||||
// fixed initial data so the server SSR and the client's first render match.
|
||||
func fixedChartData() []int { return []int{42, 17, 63, 28, 55, 9, 71} }
|
||||
|
||||
func randomValues() []int {
|
||||
v := make([]int, len(chartLabels))
|
||||
for i := range v {
|
||||
v[i] = rand.Intn(95) + 5
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func renderSVG(c interface {
|
||||
Render(chart.RendererProvider, io.Writer) error
|
||||
}) string {
|
||||
var buf bytes.Buffer
|
||||
if c.Render(chart.SVG, &buf) != nil {
|
||||
return "<p class=\"text-danger m-0\">chart error</p>"
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func barSVG(values []int) string {
|
||||
bars := make([]chart.Value, len(values))
|
||||
for i, v := range values {
|
||||
bars[i] = chart.Value{Value: float64(v), Label: chartLabels[i%len(chartLabels)]}
|
||||
}
|
||||
return renderSVG(&chart.BarChart{
|
||||
Title: "Weekly values (bar)",
|
||||
TitleStyle: chart.Style{FontSize: 15},
|
||||
Background: chart.Style{Padding: chart.Box{Top: 48, Left: 16, Right: 16, Bottom: 16}},
|
||||
Height: 320, BarWidth: 48, Bars: bars,
|
||||
})
|
||||
}
|
||||
|
||||
func pieSVG(values []int) string {
|
||||
vs := make([]chart.Value, len(values))
|
||||
for i, v := range values {
|
||||
vs[i] = chart.Value{Value: float64(v), Label: chartLabels[i%len(chartLabels)]}
|
||||
}
|
||||
return renderSVG(&chart.PieChart{
|
||||
Title: "Share by day (pie)",
|
||||
TitleStyle: chart.Style{FontSize: 15},
|
||||
Background: chart.Style{Padding: chart.Box{Top: 48}},
|
||||
Width: 320, Height: 320, Values: vs,
|
||||
})
|
||||
}
|
||||
|
||||
//gowasm:page /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.")),
|
||||
),
|
||||
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))),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
13
go/cmd/examples/go-wasm-web/app/client.gen.go
Normal file
13
go/cmd/examples/go-wasm-web/app/client.gen.go
Normal file
@@ -0,0 +1,13 @@
|
||||
// Code generated by wasmgen. DO NOT EDIT.
|
||||
|
||||
//go:build js && wasm
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"kjol/rsc"
|
||||
"kjol/vdom"
|
||||
)
|
||||
|
||||
// ServerCounter is a generated client stub for the server component of the same name.
|
||||
func ServerCounter() func() *vdom.VNode { return rsc.Mount("ServerCounter") }
|
||||
147
go/cmd/examples/go-wasm-web/app/data.go
Normal file
147
go/cmd/examples/go-wasm-web/app/data.go
Normal file
@@ -0,0 +1,147 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"kjol/httputil"
|
||||
. "kjol/vdom"
|
||||
ui "kjol/webui"
|
||||
)
|
||||
|
||||
// Quote is the payload the /api/quotes endpoint returns. The server encodes a
|
||||
// []Quote with httputil.RespondGob; the client decodes it straight back into
|
||||
// []Quote — the SAME Go type, no JSON, no hand-written unmarshalling.
|
||||
type Quote struct {
|
||||
Author string
|
||||
Text string
|
||||
}
|
||||
|
||||
// repoInfo is a subset of GitHub's repo JSON (a third-party API), tagged for
|
||||
// json decoding.
|
||||
type repoInfo struct {
|
||||
FullName string `json:"full_name"`
|
||||
Description string `json:"description"`
|
||||
Stars int `json:"stargazers_count"`
|
||||
}
|
||||
|
||||
//gowasm:page /data layout=app
|
||||
func DataPage(d Deps) func() *VNode {
|
||||
// (1) gob from our own server via httputil.RespondGob / FetchGob.
|
||||
quotes := NewSignal([]Quote{})
|
||||
qLoading := NewSignal(true)
|
||||
qErr := NewSignal("")
|
||||
// (2) JSON from a third-party API (GitHub), for a user-entered repo.
|
||||
repo := NewSignal(repoInfo{})
|
||||
rLoading := NewSignal(true)
|
||||
rErr := NewSignal("")
|
||||
repoQuery := NewSignal("golang/go")
|
||||
started := false
|
||||
|
||||
// fetchRepo loads owner/name from the GitHub API into the repo signal.
|
||||
fetchRepo := func(q string) {
|
||||
q = strings.Trim(strings.TrimSpace(q), "/")
|
||||
if q == "" {
|
||||
rErr.Set("enter a repo as owner/name")
|
||||
rLoading.Set(false)
|
||||
return
|
||||
}
|
||||
rErr.Set("")
|
||||
rLoading.Set(true)
|
||||
httputil.FetchJSON("https://api.github.com/repos/"+q, func(r repoInfo, err error) {
|
||||
if err != nil {
|
||||
rErr.Set(err.Error())
|
||||
} else {
|
||||
repo.Set(r)
|
||||
}
|
||||
rLoading.Set(false)
|
||||
})
|
||||
}
|
||||
|
||||
return func() *VNode {
|
||||
// Fire the initial fetches once, on the client (no transport on the server,
|
||||
// so SSR ships the loading state and the client takes over).
|
||||
if !started {
|
||||
started = true
|
||||
httputil.FetchGob("/api/quotes", func(qs []Quote, err error) {
|
||||
if err != nil {
|
||||
qErr.Set(err.Error())
|
||||
} else {
|
||||
quotes.Set(qs)
|
||||
}
|
||||
qLoading.Set(false)
|
||||
})
|
||||
fetchRepo(repoQuery.Get())
|
||||
}
|
||||
|
||||
return 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.")),
|
||||
),
|
||||
|
||||
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.")),
|
||||
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.")),
|
||||
row("mb-4 flex items-end gap-2",
|
||||
row("flex grow flex-col gap-1 max-w-sm",
|
||||
ui.FormLabel(ui.FormLabelProps{}, Text("GitHub repo (owner/name)")),
|
||||
ui.FormInput(ui.FormInputProps{
|
||||
Value: repoQuery.Get(),
|
||||
Placeholder: "golang/go",
|
||||
OnInput: func(v string) { repoQuery.Set(v) },
|
||||
}),
|
||||
),
|
||||
ui.Button(ui.ButtonProps{Color: ui.ButtonPrimary, Text: "Fetch", OnClick: func() { fetchRepo(repoQuery.Get()) }}),
|
||||
),
|
||||
repoBody(rLoading.Get(), rErr.Get(), repo.Get()),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func quotesBody(loading bool, failed string, quotes []Quote) *VNode {
|
||||
switch {
|
||||
case failed != "":
|
||||
return ui.Alert(ui.AlertRed, "Fetch failed", Text(failed))
|
||||
case loading:
|
||||
return ui.Loader()
|
||||
default:
|
||||
cards := make([]*VNode, 0, len(quotes))
|
||||
for _, q := range quotes {
|
||||
cards = append(cards, ui.BorderCard("",
|
||||
P(Attr("class", "text-neutral-800"), Text("“"+q.Text+"”")),
|
||||
P(Attr("class", "mt-2 text-sm text-neutral-500"), Text("— "+q.Author)),
|
||||
))
|
||||
}
|
||||
return row("grid gap-3 sm:grid-cols-2", cards...)
|
||||
}
|
||||
}
|
||||
|
||||
func repoBody(loading bool, failed string, r repoInfo) *VNode {
|
||||
switch {
|
||||
case failed != "":
|
||||
return ui.Alert(ui.AlertRed, "Fetch failed", Text(failed))
|
||||
case loading:
|
||||
return ui.Loader()
|
||||
default:
|
||||
return ui.BorderCard("",
|
||||
row("flex items-center gap-2",
|
||||
Strong(Attr("class", "text-neutral-800"), 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)),
|
||||
)
|
||||
}
|
||||
}
|
||||
186
go/cmd/examples/go-wasm-web/app/kit.go
Normal file
186
go/cmd/examples/go-wasm-web/app/kit.go
Normal file
@@ -0,0 +1,186 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
. "kjol/vdom"
|
||||
ui "kjol/webui"
|
||||
)
|
||||
|
||||
// row is a flex/grid container helper (appends *VNode children as Mods).
|
||||
func row(class string, children ...*VNode) *VNode {
|
||||
mods := []Mod{Attr("class", class)}
|
||||
for _, c := range children {
|
||||
mods = append(mods, c)
|
||||
}
|
||||
return Div(mods...)
|
||||
}
|
||||
|
||||
// kitSection wraps a labeled demo block in a card.
|
||||
func kitSection(title string, body ...*VNode) *VNode {
|
||||
return ui.Card("",
|
||||
ui.CardHeader("", Text(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)),
|
||||
El("td", Attr("class", "px-3 py-2 text-sm text-right"), status),
|
||||
)
|
||||
}
|
||||
|
||||
//gowasm:page /kit layout=app
|
||||
func KitPage(d Deps) func() *VNode {
|
||||
// Interactive demos own their state via signals (a write re-renders).
|
||||
tab := NewSignal(0)
|
||||
acc := NewSignal(0)
|
||||
notify := NewSignal(true)
|
||||
span := NewSignal("week")
|
||||
modalOpen := NewSignal(false)
|
||||
menuOpen := NewSignal(false)
|
||||
name := NewSignal("")
|
||||
email := NewSignal("")
|
||||
plan := NewSignal("pro")
|
||||
|
||||
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).")),
|
||||
),
|
||||
|
||||
kitSection("Buttons",
|
||||
row("flex flex-wrap items-center gap-2",
|
||||
ui.Button(ui.ButtonProps{Color: ui.ButtonPrimary, Text: "Primary"}),
|
||||
ui.Button(ui.ButtonProps{Color: ui.ButtonGreen, Text: "Green"}),
|
||||
ui.Button(ui.ButtonProps{Color: ui.ButtonRed, Text: "Red"}),
|
||||
ui.Button(ui.ButtonProps{Color: ui.ButtonBlue, Text: "Blue"}),
|
||||
ui.Button(ui.ButtonProps{Color: ui.ButtonNeutral, Text: "Neutral"}),
|
||||
),
|
||||
row("flex flex-wrap items-center gap-2",
|
||||
ui.Button(ui.ButtonProps{Color: ui.ButtonPrimary, Outline: true, Text: "Outline"}),
|
||||
ui.Button(ui.ButtonProps{Color: ui.ButtonRed, Outline: true, Text: "Danger"}),
|
||||
ui.Button(ui.ButtonProps{Color: ui.ButtonGreen, Small: true, Icon: "check", Text: "Small + icon"}),
|
||||
ui.Button(ui.ButtonProps{Color: ui.ButtonNeutral, Icon: "plus"}),
|
||||
ui.Button(ui.ButtonProps{Color: ui.ButtonPrimary, Text: "Disabled", Disabled: true}),
|
||||
),
|
||||
),
|
||||
|
||||
kitSection("Badges",
|
||||
row("flex flex-wrap items-center gap-2",
|
||||
ui.Badge(ui.BadgeProps{Color: ui.BadgeGreen}, Text("active")),
|
||||
ui.Badge(ui.BadgeProps{Color: ui.BadgeRed}, Text("failed")),
|
||||
ui.Badge(ui.BadgeProps{Color: ui.BadgeBlue}, Text("info")),
|
||||
ui.Badge(ui.BadgeProps{Color: ui.BadgeAmber, Pill: true}, Text("pending")),
|
||||
ui.Badge(ui.BadgeProps{Color: ui.BadgeNeutral}, Text("default")),
|
||||
ui.Badge(ui.BadgeProps{Color: ui.BadgeMuted}, Text("muted")),
|
||||
),
|
||||
),
|
||||
|
||||
kitSection("Alerts",
|
||||
ui.Alert(ui.AlertBlue, "Heads up", Text("An informational message with a header.")),
|
||||
ui.Alert(ui.AlertGreen, "", Text("A success alert without a header.")),
|
||||
ui.Alert(ui.AlertYellow, "Warning", Text("Something needs your attention.")),
|
||||
ui.Alert(ui.AlertRed, "Error", Text("Something went wrong.")),
|
||||
),
|
||||
|
||||
kitSection("Toggles & segmented control",
|
||||
ui.ToggleSwitch(notify.Get(), func(v bool) { notify.Set(v) }, "Email notifications", "Send me product updates", false, ""),
|
||||
ui.SegmentedButtons([]ui.SegmentedButtonOption{
|
||||
{Value: "day", Label: "Day"},
|
||||
{Value: "week", Label: "Week"},
|
||||
{Value: "month", Label: "Month"},
|
||||
}, span.Get(), func(v string) { span.Set(v) }, false, "max-w-xs"),
|
||||
),
|
||||
|
||||
kitSection("Tabs",
|
||||
ui.TabGroup(ui.TabGroupProps{
|
||||
Items: []ui.TabItem{
|
||||
{Title: "Overview", Content: P(Attr("class", "pt-3 text-sm text-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)."))},
|
||||
},
|
||||
ActiveIndex: tab.Get(),
|
||||
OnTabChange: func(i int) { tab.Set(i) },
|
||||
}),
|
||||
),
|
||||
|
||||
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."))},
|
||||
}, acc.Get(), func(i int) { acc.Set(i) }),
|
||||
),
|
||||
|
||||
kitSection("Forms",
|
||||
row("grid gap-4 sm:grid-cols-3",
|
||||
row("flex flex-col gap-1", ui.FormLabel(ui.FormLabelProps{}, Text("Name")),
|
||||
ui.FormInput(ui.FormInputProps{Value: name.Get(), Placeholder: "Ada Lovelace", OnInput: func(v string) { name.Set(v) }})),
|
||||
row("flex flex-col gap-1", ui.FormLabel(ui.FormLabelProps{}, Text("Email")),
|
||||
ui.FormEmailInput(ui.FormInputProps{Value: email.Get(), Placeholder: "ada@example.com", OnInput: func(v string) { email.Set(v) }}, true)),
|
||||
row("flex flex-col gap-1", ui.FormLabel(ui.FormLabelProps{}, Text("Plan")),
|
||||
ui.FormSelect(ui.FormSelectProps{Value: plan.Get(), OnChange: func(v string) { plan.Set(v) }},
|
||||
ui.FormOption("free", "Free", false),
|
||||
ui.FormOption("pro", "Pro", false),
|
||||
ui.FormOption("enterprise", "Enterprise", false))),
|
||||
),
|
||||
P(Attr("class", "text-xs text-neutral-500"),
|
||||
Text("Live: name=\""+name.Get()+"\" email=\""+email.Get()+"\" plan=\""+plan.Get()+"\"")),
|
||||
),
|
||||
|
||||
kitSection("Table",
|
||||
ui.PrettyTable(
|
||||
[]ui.PrettyTableColumn{
|
||||
{DisplayName: "Name"},
|
||||
{DisplayName: "Plan"},
|
||||
{DisplayName: "Status", DisplayPosition: ui.PrettyTableColRight},
|
||||
},
|
||||
ui.PrettyTableOptions{Hover: true, Alternate: true, SurroundingBorder: true, HeaderBorderY: true},
|
||||
ptRow("Ada Lovelace", "Pro", ui.Badge(ui.BadgeProps{Color: ui.BadgeGreen}, Text("active"))),
|
||||
ptRow("Alan Turing", "Free", ui.Badge(ui.BadgeProps{Color: ui.BadgeNeutral}, Text("trial"))),
|
||||
ptRow("Grace Hopper", "Enterprise", ui.Badge(ui.BadgeProps{Color: ui.BadgeBlue}, Text("invited"))),
|
||||
),
|
||||
),
|
||||
|
||||
kitSection("Overlays (interactive)",
|
||||
row("flex flex-wrap items-center gap-4",
|
||||
// Modal, toggled by a signal.
|
||||
ui.Button(ui.ButtonProps{Color: ui.ButtonPrimary, Text: "Open modal", OnClick: func() { modalOpen.Set(true) }}),
|
||||
// Menu, toggled by a signal.
|
||||
ui.Menu("relative inline-block",
|
||||
ui.MenuTrigger(func() { menuOpen.Set(!menuOpen.Get()) }, "",
|
||||
ui.Button(ui.ButtonProps{Color: ui.ButtonWhite, Text: "Menu ▾"})),
|
||||
ui.MenuContent(menuOpen.Get(), ui.MenuPlacementBottomStart, "",
|
||||
ui.MenuItem(ui.MenuItemProps{Icon: "check", OnClick: func() { menuOpen.Set(false) }}, Text("Profile")),
|
||||
ui.MenuItem(ui.MenuItemProps{OnClick: func() { menuOpen.Set(false) }}, Text("Settings")),
|
||||
ui.MenuDivider(""),
|
||||
ui.MenuItem(ui.MenuItemProps{OnClick: func() { menuOpen.Set(false) }}, Text("Sign out")),
|
||||
),
|
||||
),
|
||||
// Tooltip (pure CSS hover).
|
||||
ui.HoverTooltip(Span(Text("A CSS-only tooltip")), "top", "",
|
||||
ui.Button(ui.ButtonProps{Color: ui.ButtonNeutral, Outline: true, Text: "Hover me"})),
|
||||
),
|
||||
ui.Modal(ui.ModalProps{
|
||||
IsOpen: modalOpen.Get(),
|
||||
OnClose: func() { modalOpen.Set(false) },
|
||||
Size: ui.ModalMedium,
|
||||
Header: H3(Attr("class", "text-lg font-semibold text-text-heading"), Text("Example modal")),
|
||||
},
|
||||
P(Attr("class", "text-neutral-600"), Text("This modal is toggled by a signal; the backdrop and close button round-trip through OnClose.")),
|
||||
),
|
||||
),
|
||||
|
||||
ui.Alert(ui.AlertGray, "About this page",
|
||||
Text("Floating/positioned components (menus, tooltips, modals, dropdowns) are ported as "+
|
||||
"structure + Tailwind + signal/event wiring — the neutral runtime has no floating-ui, "+
|
||||
"portals, or element measurement, so positioning is approximated with static classes.")),
|
||||
)
|
||||
}
|
||||
}
|
||||
203
go/cmd/examples/go-wasm-web/app/pages.go
Normal file
203
go/cmd/examples/go-wasm-web/app/pages.go
Normal file
@@ -0,0 +1,203 @@
|
||||
// Package app holds the go-wasm-web example's pages and components as
|
||||
// standalone, platform-neutral functions (SSR on the server, hydrate on the
|
||||
// client). UI is built from the kjol webui kit + Tailwind utility classes.
|
||||
//
|
||||
// Directives (processed by kjol/cmd/wasmgen at build time):
|
||||
//
|
||||
// //gowasm:page <path> [static] [layout=<name>] a route (static => SSR'd)
|
||||
// //gowasm:layout <name> a func(Deps, *VNode) *VNode wrapper
|
||||
// //gowasm:server (see server_counter.go) a server component
|
||||
package app
|
||||
|
||||
//go:generate go run kjol/cmd/wasmgen .
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
. "kjol/vdom"
|
||||
ui "kjol/webui"
|
||||
)
|
||||
|
||||
// Deps are the client-only capabilities, injected so pages stay neutral.
|
||||
type Deps struct {
|
||||
Path func() string
|
||||
Navigate func(string)
|
||||
}
|
||||
|
||||
func itoa(n int) string { return strconv.Itoa(n) }
|
||||
|
||||
// Layout wraps a page's content with shared chrome (declared with //gowasm:layout,
|
||||
// selected per route via `layout=`; the generated LayoutFor dispatches by name).
|
||||
type Layout func(d Deps, content *VNode) *VNode
|
||||
|
||||
// Shell renders the current route's page inside its declared layout.
|
||||
func Shell(d Deps, routes map[string]func() *VNode) *VNode {
|
||||
path := d.Path()
|
||||
var content *VNode
|
||||
if page := routes[path]; page != nil {
|
||||
content = page()
|
||||
} else {
|
||||
content = notFound(path)
|
||||
}
|
||||
return LayoutFor(d, path, content)
|
||||
}
|
||||
|
||||
func notFound(path string) *VNode {
|
||||
return Div(Attr("class", "py-10"),
|
||||
H2(Attr("class", "text-xl font-semibold text-neutral-800 mb-2"), Text("Page not found")),
|
||||
P(Attr("class", "text-neutral-500"), Text("No route matches "+path+".")),
|
||||
)
|
||||
}
|
||||
|
||||
// --- layouts (Tailwind chrome) -------------------------------------------
|
||||
|
||||
//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")),
|
||||
Ul(Attr("class", "ml-auto flex items-center gap-1"),
|
||||
navItem(d, "/", "Home", 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 →"))),
|
||||
))),
|
||||
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.")),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
//gowasm:layout app
|
||||
func AppLayout(d Deps, content *VNode) *VNode {
|
||||
return Div(Attr("class", "min-h-screen"),
|
||||
Nav(Attr("class", "app-nav border-b border-neutral-800 bg-neutral-900"),
|
||||
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-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, "/kit", "UI Kit", true)),
|
||||
Ul(Attr("class", "ml-auto flex items-center"),
|
||||
navItem(d, "/", "Home", true)),
|
||||
)),
|
||||
Main(Attr("class", "mx-auto max-w-5xl px-4 py-8"), content),
|
||||
)
|
||||
}
|
||||
|
||||
// navItem is a nav link with an active state; dark switches to on-dark colors.
|
||||
func navItem(d Deps, path, label string, dark bool) *VNode {
|
||||
active := d.Path() == path
|
||||
var cls string
|
||||
switch {
|
||||
case dark && active:
|
||||
cls = "active rounded-default px-3 py-1.5 text-sm font-medium bg-white/10 text-white"
|
||||
case dark:
|
||||
cls = "rounded-default px-3 py-1.5 text-sm font-medium text-neutral-300 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"
|
||||
default:
|
||||
cls = "rounded-default px-3 py-1.5 text-sm font-medium text-neutral-600 hover:bg-neutral-100 hover:text-neutral-900"
|
||||
}
|
||||
return Li(A(Attr("class", cls+" no-underline"), Attr("href", path), navigate(d, path), Text(label)))
|
||||
}
|
||||
|
||||
// navigate intercepts a link click for client-side SPA navigation (Navigate is
|
||||
// nil on the server, so the anchor falls back to a normal navigation).
|
||||
func navigate(d Deps, path string) Mod {
|
||||
return OnEvent(EVENT_CLICK, func(e Event) {
|
||||
if d.Navigate != nil {
|
||||
e.PreventDefault()
|
||||
d.Navigate(path)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Counter is a presentational client component; state is owned by the caller.
|
||||
func Counter(label string, count *Signal[int]) *VNode {
|
||||
return Div(Attr("class", "counter flex items-center gap-3 rounded-default border border-neutral-200 bg-white px-4 py-3 shadow-xs"),
|
||||
Span(Attr("class", "font-medium text-neutral-700"), Text(label+": ")),
|
||||
Strong(Attr("class", "badge inline-flex min-w-8 items-center justify-center rounded-full bg-primary px-2.5 py-0.5 text-sm font-semibold text-white"), Text(itoa(count.Get()))),
|
||||
Div(Attr("class", "ml-auto flex gap-1"),
|
||||
ui.Button(ui.ButtonProps{Color: ui.ButtonSecondary, Small: true, Text: "−", OnClick: func() { count.Update(func(v int) int { return v - 1 }) }}),
|
||||
ui.Button(ui.ButtonProps{Color: ui.ButtonPrimary, Small: true, Text: "+", OnClick: func() { count.Update(func(v int) int { return v + 1 }) }}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
//gowasm:page / static layout=public
|
||||
func HomePage(d Deps) func() *VNode {
|
||||
a := NewSignal(0)
|
||||
b := NewSignal(0)
|
||||
dark := NewSignal(false)
|
||||
return func() *VNode {
|
||||
return Div(Attr("class", "space-y-8"),
|
||||
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.")),
|
||||
),
|
||||
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")),
|
||||
),
|
||||
Div(Attr("class", "mt-4"),
|
||||
ui.ToggleSwitch(dark.Get(), func(v bool) { dark.Set(v) }, "Dark mode", "Just a demo toggle", false, "")),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
//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.")),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
//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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
44
go/cmd/examples/go-wasm-web/app/routes.gen.go
Normal file
44
go/cmd/examples/go-wasm-web/app/routes.gen.go
Normal file
@@ -0,0 +1,44 @@
|
||||
// Code generated by wasmgen. DO NOT EDIT.
|
||||
package app
|
||||
|
||||
import "kjol/vdom"
|
||||
|
||||
// Routes maps each //gowasm:page path to its instantiated render function.
|
||||
func Routes(d Deps) map[string]func() *vdom.VNode {
|
||||
return map[string]func() *vdom.VNode{
|
||||
"/": HomePage(d),
|
||||
"/about": AboutPage(d),
|
||||
"/chart": ChartPage(d),
|
||||
"/data": DataPage(d),
|
||||
"/kit": KitPage(d),
|
||||
"/server": ServerPage(d),
|
||||
}
|
||||
}
|
||||
|
||||
// StaticPaths are the routes the server pre-renders (SSR); others render client-side.
|
||||
var StaticPaths = map[string]bool{
|
||||
"/": true,
|
||||
"/about": true,
|
||||
"/chart": true,
|
||||
}
|
||||
|
||||
// RouteLayout maps each route to the name of the layout that wraps it.
|
||||
var RouteLayout = map[string]string{
|
||||
"/": "public",
|
||||
"/about": "public",
|
||||
"/chart": "app",
|
||||
"/data": "app",
|
||||
"/kit": "app",
|
||||
"/server": "app",
|
||||
}
|
||||
|
||||
// LayoutFor wraps a page's content in the layout declared for its route.
|
||||
func LayoutFor(d Deps, path string, content *vdom.VNode) *vdom.VNode {
|
||||
switch RouteLayout[path] {
|
||||
case "app":
|
||||
return AppLayout(d, content)
|
||||
case "public":
|
||||
return PublicLayout(d, content)
|
||||
}
|
||||
return AppLayout(d, content)
|
||||
}
|
||||
11
go/cmd/examples/go-wasm-web/app/server.gen.go
Normal file
11
go/cmd/examples/go-wasm-web/app/server.gen.go
Normal file
@@ -0,0 +1,11 @@
|
||||
// Code generated by wasmgen. DO NOT EDIT.
|
||||
|
||||
//go:build !(js && wasm)
|
||||
|
||||
package app
|
||||
|
||||
import "kjol/rsc"
|
||||
|
||||
func init() {
|
||||
rsc.Register("ServerCounter", ServerCounter)
|
||||
}
|
||||
117
go/cmd/examples/go-wasm-web/app/server_counter.go
Normal file
117
go/cmd/examples/go-wasm-web/app/server_counter.go
Normal file
@@ -0,0 +1,117 @@
|
||||
//go:build !(js && wasm)
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
chart "github.com/wcharczuk/go-chart/v2"
|
||||
|
||||
. "kjol/vdom"
|
||||
ui "kjol/webui"
|
||||
)
|
||||
|
||||
// ServerCounter is a SERVER component — note it's written exactly like a client
|
||||
// component (same builders, signals, On handlers). The //gowasm:server directive
|
||||
// makes the build generate a client stub so calling ServerCounter() on the
|
||||
// frontend is identical to calling any component; the state and this render run
|
||||
// on the server (its chart is computed there with go-chart), and clicks
|
||||
// round-trip over /rsc.
|
||||
//
|
||||
// The chart plots the counter value against the wall-clock time of each click
|
||||
// (milliseconds since the first click), so spacing clicks out spreads the data
|
||||
// points along the x-axis. Because the component is stateless on the server, the
|
||||
// click points live in a signal that round-trips with the rest of its state
|
||||
// (a plain slice would reset on every request).
|
||||
//
|
||||
//gowasm:server
|
||||
func ServerCounter() func() *VNode {
|
||||
count := NewSignal(0)
|
||||
points := NewSignal([]clickPoint{})
|
||||
bump := func(delta int) {
|
||||
count.Set(count.Get() + delta)
|
||||
points.Set(append(points.Get(), clickPoint{T: time.Now().UnixMilli(), V: count.Get()}))
|
||||
}
|
||||
return func() *VNode {
|
||||
return ui.Card("",
|
||||
Div(Attr("class", "flex items-center gap-2 mb-3"),
|
||||
Span(Attr("class", "text-neutral-700"), 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"),
|
||||
Raw(clickChartSVG(points.Get()))),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// clickPoint records one click: its wall-clock time and the resulting counter
|
||||
// value. Exported fields so the signal's JSON snapshot round-trips it.
|
||||
type clickPoint struct {
|
||||
T int64 // click time, Unix milliseconds
|
||||
V int // counter value after the click
|
||||
}
|
||||
|
||||
// clickChartSVG plots counter value vs. time-of-click (ms since the first
|
||||
// click) as a line graph. Explicit axis ranges keep it valid for the tricky
|
||||
// cases (a single click, or several clicks within the same millisecond).
|
||||
func clickChartSVG(points []clickPoint) string {
|
||||
if len(points) == 0 {
|
||||
return `<span class="text-muted">Click + / − to plot the counter over time (ms since the first click).</span>`
|
||||
}
|
||||
t0 := points[0].T
|
||||
xs := make([]float64, len(points))
|
||||
ys := make([]float64, len(points))
|
||||
minY, maxY := 0.0, 0.0 // keep the zero baseline in view for context
|
||||
for i, p := range points {
|
||||
xs[i] = float64(p.T - t0)
|
||||
ys[i] = float64(p.V)
|
||||
if ys[i] < minY {
|
||||
minY = ys[i]
|
||||
}
|
||||
if ys[i] > maxY {
|
||||
maxY = ys[i]
|
||||
}
|
||||
}
|
||||
maxX := xs[len(xs)-1]
|
||||
if maxX <= 0 {
|
||||
maxX = 1 // rapid or single clicks: avoid a zero-width x-range
|
||||
}
|
||||
if minY == maxY {
|
||||
maxY++ // avoid a zero-height y-range
|
||||
}
|
||||
graph := chart.Chart{
|
||||
Title: "Counter over time (computed on the server)",
|
||||
TitleStyle: chart.Style{FontSize: 14},
|
||||
Background: chart.Style{Padding: chart.Box{Top: 48, Left: 20, Right: 20, Bottom: 40}},
|
||||
Height: 260,
|
||||
XAxis: chart.XAxis{
|
||||
Name: "ms since first click",
|
||||
Range: &chart.ContinuousRange{Min: 0, Max: maxX},
|
||||
},
|
||||
YAxis: chart.YAxis{
|
||||
Name: "counter",
|
||||
Range: &chart.ContinuousRange{Min: minY, Max: maxY},
|
||||
},
|
||||
Series: []chart.Series{
|
||||
chart.ContinuousSeries{
|
||||
XValues: xs,
|
||||
YValues: ys,
|
||||
Style: chart.Style{
|
||||
StrokeColor: chart.ColorGreen, StrokeWidth: 2,
|
||||
DotColor: chart.ColorGreen, DotWidth: 4, // a dot at each click
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if graph.Render(chart.SVG, &buf) != nil {
|
||||
return `<span class="text-danger">chart error</span>`
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
36
go/cmd/examples/go-wasm-web/build.sh
Executable file
36
go/cmd/examples/go-wasm-web/build.sh
Executable file
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env bash
|
||||
# Pre-compile step: directive codegen, Tailwind CSS, WebAssembly build, JS shim.
|
||||
# Run the dev server instead (go run ./server) for hot reload; this is for a
|
||||
# one-off/production-style build. Run from anywhere.
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
KJOL_GO="$(cd ../../.. && pwd)"
|
||||
|
||||
echo "==> Generating directive glue (//gowasm:page, //gowasm:layout, //gowasm:server)"
|
||||
go run kjol/cmd/wasmgen ./app
|
||||
|
||||
echo "==> Compiling Tailwind CSS -> wwwroot/app.css (scanning webui + Go markup)"
|
||||
# Run twcss from the kjol module root so the Tailwind engine's deps resolve there
|
||||
# (not in this example module — keeps its go.mod lean).
|
||||
( cd "$KJOL_GO" && go run ./cmd/twcss \
|
||||
-entry cmd/examples/go-wasm-web/css/app.css \
|
||||
-out cmd/examples/go-wasm-web/wwwroot/app.css \
|
||||
-base . \
|
||||
'webui/**/*.go' \
|
||||
'cmd/examples/go-wasm-web/app/**/*.go' \
|
||||
'cmd/examples/go-wasm-web/server/**/*.go' )
|
||||
|
||||
echo "==> Compiling ./wasm to wwwroot/app.wasm (GOOS=js GOARCH=wasm)"
|
||||
GOOS=js GOARCH=wasm go build -o wwwroot/app.wasm ./wasm
|
||||
|
||||
echo "==> Copying Go's wasm_exec.js shim into wwwroot/"
|
||||
GOROOT="$(go env GOROOT)"
|
||||
shim="$GOROOT/lib/wasm/wasm_exec.js" # Go >= 1.24
|
||||
[ -f "$shim" ] || shim="$GOROOT/misc/wasm/wasm_exec.js" # Go <= 1.23
|
||||
rm -f wwwroot/wasm_exec.js # GOROOT copy is read-only; remove before overwriting
|
||||
cp "$shim" wwwroot/wasm_exec.js
|
||||
chmod u+w wwwroot/wasm_exec.js
|
||||
|
||||
echo "==> Done. Run the server with: go run ./server"
|
||||
echo " then open http://localhost:8085"
|
||||
14
go/cmd/examples/go-wasm-web/css/app.css
Normal file
14
go/cmd/examples/go-wasm-web/css/app.css
Normal file
@@ -0,0 +1,14 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* App-side design tokens the webui kit references (Tailwind v4 @theme). Brand
|
||||
values live with the app; the kit stays generic. */
|
||||
@theme {
|
||||
--radius-default: 0.375rem;
|
||||
|
||||
--color-primary: #4f46e5;
|
||||
--color-primary-hover: #4338ca;
|
||||
|
||||
--color-text-heading: #111827;
|
||||
--color-text-on-dark: #f9fafb;
|
||||
--color-text-on-dark-muted: #9ca3af;
|
||||
}
|
||||
18
go/cmd/examples/go-wasm-web/go.mod
Normal file
18
go/cmd/examples/go-wasm-web/go.mod
Normal file
@@ -0,0 +1,18 @@
|
||||
// The example is its own module so its go-chart dependency (and freetype /
|
||||
// x/image) stays out of the kjol module — kjol's engine packages are
|
||||
// stdlib-only. kjol is resolved locally via the replace below (no publish step).
|
||||
module gowasmweb
|
||||
|
||||
go 1.26.3
|
||||
|
||||
require (
|
||||
github.com/wcharczuk/go-chart/v2 v2.1.2
|
||||
kjol v0.0.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
|
||||
golang.org/x/image v0.18.0 // indirect
|
||||
)
|
||||
|
||||
replace kjol => ../../..
|
||||
66
go/cmd/examples/go-wasm-web/go.sum
Normal file
66
go/cmd/examples/go-wasm-web/go.sum
Normal file
@@ -0,0 +1,66 @@
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/wcharczuk/go-chart/v2 v2.1.2 h1:Y17/oYNuXwZg6TFag06qe8sBajwwsuvPiJJXcUcLL6E=
|
||||
github.com/wcharczuk/go-chart/v2 v2.1.2/go.mod h1:Zi4hbaqlWpYajnXB2K22IUYVXRXaLfSGNNR7P4ukyyQ=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ=
|
||||
golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
BIN
go/cmd/examples/go-wasm-web/server/__debug_bin283327599
Executable file
BIN
go/cmd/examples/go-wasm-web/server/__debug_bin283327599
Executable file
Binary file not shown.
117
go/cmd/examples/go-wasm-web/server/main.go
Normal file
117
go/cmd/examples/go-wasm-web/server/main.go
Normal file
@@ -0,0 +1,117 @@
|
||||
// Command server runs the go-wasm-web example on kjol's reusable wasmdevserver:
|
||||
// it SSRs the app's static routes, hosts the /rsc server-component endpoint, and
|
||||
// hot-swaps the wasm into the browser on change. It shows the coupling
|
||||
// inversion — the framework (wasmdevserver) imports no app code; the app injects
|
||||
// Build/Render/Document here.
|
||||
//
|
||||
// Run it from THIS directory (the relative paths below are resolved against it):
|
||||
//
|
||||
// go run ./server # from cmd/examples/go-wasm-web
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
|
||||
"kjol/httputil"
|
||||
"kjol/vdom"
|
||||
"kjol/wasmdevserver"
|
||||
|
||||
"gowasmweb/app"
|
||||
)
|
||||
|
||||
func main() {
|
||||
addr := flag.String("addr", ":8085", "listen address")
|
||||
watch := flag.Bool("watch", true, "watch sources, rebuild wasm, hot-reload")
|
||||
flag.Parse()
|
||||
|
||||
log.Fatal(wasmdevserver.Serve(wasmdevserver.Config{
|
||||
Addr: *addr,
|
||||
Dir: "./wwwroot",
|
||||
Watch: *watch,
|
||||
WatchDirs: []string{"app", "wasm", "css", "../../../webui", "../../../vdom", "../../../wasmruntime", "../../../rsc"}, // example + kjol engine + kit
|
||||
Build: buildWasm,
|
||||
Render: render,
|
||||
Document: document,
|
||||
Handle: apiRoutes,
|
||||
}))
|
||||
}
|
||||
|
||||
// apiRoutes registers the example's API endpoints. /api/quotes responds with a
|
||||
// gob-encoded []app.Quote (via httputil.RespondGob) — the /data page fetches and
|
||||
// decodes it on the client with encoding/gob (Go types end to end, no JSON).
|
||||
func apiRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /api/quotes", func(w http.ResponseWriter, r *http.Request) {
|
||||
httputil.RespondGob(w, http.StatusOK, sampleQuotes())
|
||||
})
|
||||
}
|
||||
|
||||
func sampleQuotes() []app.Quote {
|
||||
return []app.Quote{
|
||||
{Author: "Rob Pike", Text: "A little copying is better than a little dependency."},
|
||||
{Author: "Rob Pike", Text: "Don't communicate by sharing memory; share memory by communicating."},
|
||||
{Author: "Ken Thompson", Text: "When in doubt, use brute force."},
|
||||
{Author: "Alan Kay", Text: "The best way to predict the future is to invent it."},
|
||||
}
|
||||
}
|
||||
|
||||
// render SSRs a static route's #app inner HTML; ok=false ships an empty #app
|
||||
// (client-rendered). It's the same neutral render the client runs, so the client
|
||||
// hydrates it.
|
||||
func render(path string) (string, bool) {
|
||||
if !app.StaticPaths[path] {
|
||||
return "", false
|
||||
}
|
||||
deps := app.Deps{Path: func() string { return path }} // Navigate is nil on the server
|
||||
return vdom.RenderHTML(app.Shell(deps, app.Routes(deps))), true
|
||||
}
|
||||
|
||||
// document wraps the server-rendered inner HTML in the page shell. No whitespace
|
||||
// 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 {
|
||||
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>
|
||||
<link rel="stylesheet" href="/app.css" />
|
||||
</head>
|
||||
<body class="bg-neutral-50 text-neutral-900 antialiased">
|
||||
<div id="app">` + inner + `</div>
|
||||
<script src="/wasm_exec.js"></script>
|
||||
<script src="/wasmboot.js"></script>
|
||||
</body>
|
||||
</html>`
|
||||
}
|
||||
|
||||
// buildWasm runs the directive codegen (kjol/cmd/wasmgen), compiles the Tailwind
|
||||
// CSS (kjol/cmd/twcss, scanning the Go markup + webui kit), then compiles ./wasm
|
||||
// to wwwroot/app.wasm. Returned combined output is shown in the browser overlay
|
||||
// on failure.
|
||||
func buildWasm() ([]byte, error) {
|
||||
if out, err := exec.Command("go", "run", "kjol/cmd/wasmgen", "./app").CombinedOutput(); err != nil {
|
||||
return out, err
|
||||
}
|
||||
// Compile Tailwind from the kjol module root (so the engine's deps resolve),
|
||||
// scanning the webui kit + this example's Go markup for utility candidates.
|
||||
tw := exec.Command("go", "run", "./cmd/twcss",
|
||||
"-entry", "cmd/examples/go-wasm-web/css/app.css",
|
||||
"-out", "cmd/examples/go-wasm-web/wwwroot/app.css",
|
||||
"-base", ".",
|
||||
"webui/**/*.go",
|
||||
"cmd/examples/go-wasm-web/app/**/*.go",
|
||||
"cmd/examples/go-wasm-web/server/**/*.go")
|
||||
tw.Dir = "../../.." // kjol/go
|
||||
if out, err := tw.CombinedOutput(); err != nil {
|
||||
return out, err
|
||||
}
|
||||
cmd := exec.Command("go", "build", "-o", filepath.Join("wwwroot", "app.wasm"), "./wasm")
|
||||
cmd.Env = append(os.Environ(), "GOOS=js", "GOARCH=wasm")
|
||||
return cmd.CombinedOutput()
|
||||
}
|
||||
33
go/cmd/examples/go-wasm-web/wasm/main.go
Normal file
33
go/cmd/examples/go-wasm-web/wasm/main.go
Normal file
@@ -0,0 +1,33 @@
|
||||
//go:build js && wasm
|
||||
|
||||
// Command wasm is the client entry point: it wires client capabilities into the
|
||||
// (generated) routes, then hydrates the server-rendered DOM or renders fresh.
|
||||
package main
|
||||
|
||||
import (
|
||||
"gowasmweb/app"
|
||||
"kjol/httputil"
|
||||
"kjol/vdom"
|
||||
"kjol/wasmruntime"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Collect the signals created during setup so their values can be preserved
|
||||
// across an in-place hot swap. On a normal load RestoreState is nil (fresh
|
||||
// state); after a dev hot-reload it carries the previous instance's values,
|
||||
// which NewSignal restores by creation order.
|
||||
httputil.SetClientTransport(wasmruntime.FetchBytes) // typed gob/json fetches for app pages
|
||||
collector := vdom.BeginCollect(wasmruntime.RestoreState())
|
||||
router := wasmruntime.NewRouter()
|
||||
deps := app.Deps{Path: router.Path, Navigate: router.Navigate}
|
||||
routes := app.Routes(deps)
|
||||
vdom.EndCollect() // signals created later (during renders) aren't preserved
|
||||
wasmruntime.PreserveState(collector)
|
||||
|
||||
render := func() *vdom.VNode { return app.Shell(deps, routes) }
|
||||
if wasmruntime.HasServerContent() {
|
||||
wasmruntime.Hydrate(render) // static route: adopt the server-rendered DOM
|
||||
} else {
|
||||
wasmruntime.Run(render) // client-rendered route
|
||||
}
|
||||
}
|
||||
9
go/cmd/examples/go-wasm-web/wasm/main_native.go
Normal file
9
go/cmd/examples/go-wasm-web/wasm/main_native.go
Normal file
@@ -0,0 +1,9 @@
|
||||
//go:build !(js && wasm)
|
||||
|
||||
// The wasm client entry point (main.go) builds only under GOOS=js GOARCH=wasm.
|
||||
// This native placeholder keeps the package buildable on the host so a plain
|
||||
// `go build ./...` succeeds; the real client is built by build.sh / the dev
|
||||
// server with GOOS=js GOARCH=wasm.
|
||||
package main
|
||||
|
||||
func main() {}
|
||||
100
go/cmd/examples/go-wasm-web/wwwroot/app.css
Normal file
100
go/cmd/examples/go-wasm-web/wwwroot/app.css
Normal file
File diff suppressed because one or more lines are too long
BIN
go/cmd/examples/go-wasm-web/wwwroot/app.wasm
Executable file
BIN
go/cmd/examples/go-wasm-web/wwwroot/app.wasm
Executable file
Binary file not shown.
575
go/cmd/examples/go-wasm-web/wwwroot/wasm_exec.js
Normal file
575
go/cmd/examples/go-wasm-web/wwwroot/wasm_exec.js
Normal file
@@ -0,0 +1,575 @@
|
||||
// Copyright 2018 The Go Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
"use strict";
|
||||
|
||||
(() => {
|
||||
const enosys = () => {
|
||||
const err = new Error("not implemented");
|
||||
err.code = "ENOSYS";
|
||||
return err;
|
||||
};
|
||||
|
||||
if (!globalThis.fs) {
|
||||
let outputBuf = "";
|
||||
globalThis.fs = {
|
||||
constants: { O_WRONLY: -1, O_RDWR: -1, O_CREAT: -1, O_TRUNC: -1, O_APPEND: -1, O_EXCL: -1, O_DIRECTORY: -1 }, // unused
|
||||
writeSync(fd, buf) {
|
||||
outputBuf += decoder.decode(buf);
|
||||
const nl = outputBuf.lastIndexOf("\n");
|
||||
if (nl != -1) {
|
||||
console.log(outputBuf.substring(0, nl));
|
||||
outputBuf = outputBuf.substring(nl + 1);
|
||||
}
|
||||
return buf.length;
|
||||
},
|
||||
write(fd, buf, offset, length, position, callback) {
|
||||
if (offset !== 0 || length !== buf.length || position !== null) {
|
||||
callback(enosys());
|
||||
return;
|
||||
}
|
||||
const n = this.writeSync(fd, buf);
|
||||
callback(null, n);
|
||||
},
|
||||
chmod(path, mode, callback) { callback(enosys()); },
|
||||
chown(path, uid, gid, callback) { callback(enosys()); },
|
||||
close(fd, callback) { callback(enosys()); },
|
||||
fchmod(fd, mode, callback) { callback(enosys()); },
|
||||
fchown(fd, uid, gid, callback) { callback(enosys()); },
|
||||
fstat(fd, callback) { callback(enosys()); },
|
||||
fsync(fd, callback) { callback(null); },
|
||||
ftruncate(fd, length, callback) { callback(enosys()); },
|
||||
lchown(path, uid, gid, callback) { callback(enosys()); },
|
||||
link(path, link, callback) { callback(enosys()); },
|
||||
lstat(path, callback) { callback(enosys()); },
|
||||
mkdir(path, perm, callback) { callback(enosys()); },
|
||||
open(path, flags, mode, callback) { callback(enosys()); },
|
||||
read(fd, buffer, offset, length, position, callback) { callback(enosys()); },
|
||||
readdir(path, callback) { callback(enosys()); },
|
||||
readlink(path, callback) { callback(enosys()); },
|
||||
rename(from, to, callback) { callback(enosys()); },
|
||||
rmdir(path, callback) { callback(enosys()); },
|
||||
stat(path, callback) { callback(enosys()); },
|
||||
symlink(path, link, callback) { callback(enosys()); },
|
||||
truncate(path, length, callback) { callback(enosys()); },
|
||||
unlink(path, callback) { callback(enosys()); },
|
||||
utimes(path, atime, mtime, callback) { callback(enosys()); },
|
||||
};
|
||||
}
|
||||
|
||||
if (!globalThis.process) {
|
||||
globalThis.process = {
|
||||
getuid() { return -1; },
|
||||
getgid() { return -1; },
|
||||
geteuid() { return -1; },
|
||||
getegid() { return -1; },
|
||||
getgroups() { throw enosys(); },
|
||||
pid: -1,
|
||||
ppid: -1,
|
||||
umask() { throw enosys(); },
|
||||
cwd() { throw enosys(); },
|
||||
chdir() { throw enosys(); },
|
||||
}
|
||||
}
|
||||
|
||||
if (!globalThis.path) {
|
||||
globalThis.path = {
|
||||
resolve(...pathSegments) {
|
||||
return pathSegments.join("/");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!globalThis.crypto) {
|
||||
throw new Error("globalThis.crypto is not available, polyfill required (crypto.getRandomValues only)");
|
||||
}
|
||||
|
||||
if (!globalThis.performance) {
|
||||
throw new Error("globalThis.performance is not available, polyfill required (performance.now only)");
|
||||
}
|
||||
|
||||
if (!globalThis.TextEncoder) {
|
||||
throw new Error("globalThis.TextEncoder is not available, polyfill required");
|
||||
}
|
||||
|
||||
if (!globalThis.TextDecoder) {
|
||||
throw new Error("globalThis.TextDecoder is not available, polyfill required");
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder("utf-8");
|
||||
const decoder = new TextDecoder("utf-8");
|
||||
|
||||
globalThis.Go = class {
|
||||
constructor() {
|
||||
this.argv = ["js"];
|
||||
this.env = {};
|
||||
this.exit = (code) => {
|
||||
if (code !== 0) {
|
||||
console.warn("exit code:", code);
|
||||
}
|
||||
};
|
||||
this._exitPromise = new Promise((resolve) => {
|
||||
this._resolveExitPromise = resolve;
|
||||
});
|
||||
this._pendingEvent = null;
|
||||
this._scheduledTimeouts = new Map();
|
||||
this._nextCallbackTimeoutID = 1;
|
||||
|
||||
const setInt64 = (addr, v) => {
|
||||
this.mem.setUint32(addr + 0, v, true);
|
||||
this.mem.setUint32(addr + 4, Math.floor(v / 4294967296), true);
|
||||
}
|
||||
|
||||
const setInt32 = (addr, v) => {
|
||||
this.mem.setUint32(addr + 0, v, true);
|
||||
}
|
||||
|
||||
const getInt64 = (addr) => {
|
||||
const low = this.mem.getUint32(addr + 0, true);
|
||||
const high = this.mem.getInt32(addr + 4, true);
|
||||
return low + high * 4294967296;
|
||||
}
|
||||
|
||||
const loadValue = (addr) => {
|
||||
const f = this.mem.getFloat64(addr, true);
|
||||
if (f === 0) {
|
||||
return undefined;
|
||||
}
|
||||
if (!isNaN(f)) {
|
||||
return f;
|
||||
}
|
||||
|
||||
const id = this.mem.getUint32(addr, true);
|
||||
return this._values[id];
|
||||
}
|
||||
|
||||
const storeValue = (addr, v) => {
|
||||
const nanHead = 0x7FF80000;
|
||||
|
||||
if (typeof v === "number" && v !== 0) {
|
||||
if (isNaN(v)) {
|
||||
this.mem.setUint32(addr + 4, nanHead, true);
|
||||
this.mem.setUint32(addr, 0, true);
|
||||
return;
|
||||
}
|
||||
this.mem.setFloat64(addr, v, true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (v === undefined) {
|
||||
this.mem.setFloat64(addr, 0, true);
|
||||
return;
|
||||
}
|
||||
|
||||
let id = this._ids.get(v);
|
||||
if (id === undefined) {
|
||||
id = this._idPool.pop();
|
||||
if (id === undefined) {
|
||||
id = this._values.length;
|
||||
}
|
||||
this._values[id] = v;
|
||||
this._goRefCounts[id] = 0;
|
||||
this._ids.set(v, id);
|
||||
}
|
||||
this._goRefCounts[id]++;
|
||||
let typeFlag = 0;
|
||||
switch (typeof v) {
|
||||
case "object":
|
||||
if (v !== null) {
|
||||
typeFlag = 1;
|
||||
}
|
||||
break;
|
||||
case "string":
|
||||
typeFlag = 2;
|
||||
break;
|
||||
case "symbol":
|
||||
typeFlag = 3;
|
||||
break;
|
||||
case "function":
|
||||
typeFlag = 4;
|
||||
break;
|
||||
}
|
||||
this.mem.setUint32(addr + 4, nanHead | typeFlag, true);
|
||||
this.mem.setUint32(addr, id, true);
|
||||
}
|
||||
|
||||
const loadSlice = (addr) => {
|
||||
const array = getInt64(addr + 0);
|
||||
const len = getInt64(addr + 8);
|
||||
return new Uint8Array(this._inst.exports.mem.buffer, array, len);
|
||||
}
|
||||
|
||||
const loadSliceOfValues = (addr) => {
|
||||
const array = getInt64(addr + 0);
|
||||
const len = getInt64(addr + 8);
|
||||
const a = new Array(len);
|
||||
for (let i = 0; i < len; i++) {
|
||||
a[i] = loadValue(array + i * 8);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
const loadString = (addr) => {
|
||||
const saddr = getInt64(addr + 0);
|
||||
const len = getInt64(addr + 8);
|
||||
return decoder.decode(new DataView(this._inst.exports.mem.buffer, saddr, len));
|
||||
}
|
||||
|
||||
const testCallExport = (a, b) => {
|
||||
this._inst.exports.testExport0();
|
||||
return this._inst.exports.testExport(a, b);
|
||||
}
|
||||
|
||||
const timeOrigin = Date.now() - performance.now();
|
||||
this.importObject = {
|
||||
_gotest: {
|
||||
add: (a, b) => a + b,
|
||||
callExport: testCallExport,
|
||||
},
|
||||
gojs: {
|
||||
// Go's SP does not change as long as no Go code is running. Some operations (e.g. calls, getters and setters)
|
||||
// may synchronously trigger a Go event handler. This makes Go code get executed in the middle of the imported
|
||||
// function. A goroutine can switch to a new stack if the current stack is too small (see morestack function).
|
||||
// This changes the SP, thus we have to update the SP used by the imported function.
|
||||
|
||||
// func wasmExit(code int32)
|
||||
"runtime.wasmExit": (sp) => {
|
||||
sp >>>= 0;
|
||||
const code = this.mem.getInt32(sp + 8, true);
|
||||
this.exited = true;
|
||||
delete this._inst;
|
||||
delete this._values;
|
||||
delete this._goRefCounts;
|
||||
delete this._ids;
|
||||
delete this._idPool;
|
||||
this.exit(code);
|
||||
},
|
||||
|
||||
// func wasmWrite(fd uintptr, p unsafe.Pointer, n int32)
|
||||
"runtime.wasmWrite": (sp) => {
|
||||
sp >>>= 0;
|
||||
const fd = getInt64(sp + 8);
|
||||
const p = getInt64(sp + 16);
|
||||
const n = this.mem.getInt32(sp + 24, true);
|
||||
fs.writeSync(fd, new Uint8Array(this._inst.exports.mem.buffer, p, n));
|
||||
},
|
||||
|
||||
// func resetMemoryDataView()
|
||||
"runtime.resetMemoryDataView": (sp) => {
|
||||
sp >>>= 0;
|
||||
this.mem = new DataView(this._inst.exports.mem.buffer);
|
||||
},
|
||||
|
||||
// func nanotime1() int64
|
||||
"runtime.nanotime1": (sp) => {
|
||||
sp >>>= 0;
|
||||
setInt64(sp + 8, (timeOrigin + performance.now()) * 1000000);
|
||||
},
|
||||
|
||||
// func walltime() (sec int64, nsec int32)
|
||||
"runtime.walltime": (sp) => {
|
||||
sp >>>= 0;
|
||||
const msec = (new Date).getTime();
|
||||
setInt64(sp + 8, msec / 1000);
|
||||
this.mem.setInt32(sp + 16, (msec % 1000) * 1000000, true);
|
||||
},
|
||||
|
||||
// func scheduleTimeoutEvent(delay int64) int32
|
||||
"runtime.scheduleTimeoutEvent": (sp) => {
|
||||
sp >>>= 0;
|
||||
const id = this._nextCallbackTimeoutID;
|
||||
this._nextCallbackTimeoutID++;
|
||||
this._scheduledTimeouts.set(id, setTimeout(
|
||||
() => {
|
||||
this._resume();
|
||||
while (this._scheduledTimeouts.has(id)) {
|
||||
// for some reason Go failed to register the timeout event, log and try again
|
||||
// (temporary workaround for https://github.com/golang/go/issues/28975)
|
||||
console.warn("scheduleTimeoutEvent: missed timeout event");
|
||||
this._resume();
|
||||
}
|
||||
},
|
||||
getInt64(sp + 8),
|
||||
));
|
||||
this.mem.setInt32(sp + 16, id, true);
|
||||
},
|
||||
|
||||
// func clearTimeoutEvent(id int32)
|
||||
"runtime.clearTimeoutEvent": (sp) => {
|
||||
sp >>>= 0;
|
||||
const id = this.mem.getInt32(sp + 8, true);
|
||||
clearTimeout(this._scheduledTimeouts.get(id));
|
||||
this._scheduledTimeouts.delete(id);
|
||||
},
|
||||
|
||||
// func getRandomData(r []byte)
|
||||
"runtime.getRandomData": (sp) => {
|
||||
sp >>>= 0;
|
||||
crypto.getRandomValues(loadSlice(sp + 8));
|
||||
},
|
||||
|
||||
// func finalizeRef(v ref)
|
||||
"syscall/js.finalizeRef": (sp) => {
|
||||
sp >>>= 0;
|
||||
const id = this.mem.getUint32(sp + 8, true);
|
||||
this._goRefCounts[id]--;
|
||||
if (this._goRefCounts[id] === 0) {
|
||||
const v = this._values[id];
|
||||
this._values[id] = null;
|
||||
this._ids.delete(v);
|
||||
this._idPool.push(id);
|
||||
}
|
||||
},
|
||||
|
||||
// func stringVal(value string) ref
|
||||
"syscall/js.stringVal": (sp) => {
|
||||
sp >>>= 0;
|
||||
storeValue(sp + 24, loadString(sp + 8));
|
||||
},
|
||||
|
||||
// func valueGet(v ref, p string) ref
|
||||
"syscall/js.valueGet": (sp) => {
|
||||
sp >>>= 0;
|
||||
const result = Reflect.get(loadValue(sp + 8), loadString(sp + 16));
|
||||
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||
storeValue(sp + 32, result);
|
||||
},
|
||||
|
||||
// func valueSet(v ref, p string, x ref)
|
||||
"syscall/js.valueSet": (sp) => {
|
||||
sp >>>= 0;
|
||||
Reflect.set(loadValue(sp + 8), loadString(sp + 16), loadValue(sp + 32));
|
||||
},
|
||||
|
||||
// func valueDelete(v ref, p string)
|
||||
"syscall/js.valueDelete": (sp) => {
|
||||
sp >>>= 0;
|
||||
Reflect.deleteProperty(loadValue(sp + 8), loadString(sp + 16));
|
||||
},
|
||||
|
||||
// func valueIndex(v ref, i int) ref
|
||||
"syscall/js.valueIndex": (sp) => {
|
||||
sp >>>= 0;
|
||||
storeValue(sp + 24, Reflect.get(loadValue(sp + 8), getInt64(sp + 16)));
|
||||
},
|
||||
|
||||
// valueSetIndex(v ref, i int, x ref)
|
||||
"syscall/js.valueSetIndex": (sp) => {
|
||||
sp >>>= 0;
|
||||
Reflect.set(loadValue(sp + 8), getInt64(sp + 16), loadValue(sp + 24));
|
||||
},
|
||||
|
||||
// func valueCall(v ref, m string, args []ref) (ref, bool)
|
||||
"syscall/js.valueCall": (sp) => {
|
||||
sp >>>= 0;
|
||||
try {
|
||||
const v = loadValue(sp + 8);
|
||||
const m = Reflect.get(v, loadString(sp + 16));
|
||||
const args = loadSliceOfValues(sp + 32);
|
||||
const result = Reflect.apply(m, v, args);
|
||||
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||
storeValue(sp + 56, result);
|
||||
this.mem.setUint8(sp + 64, 1);
|
||||
} catch (err) {
|
||||
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||
storeValue(sp + 56, err);
|
||||
this.mem.setUint8(sp + 64, 0);
|
||||
}
|
||||
},
|
||||
|
||||
// func valueInvoke(v ref, args []ref) (ref, bool)
|
||||
"syscall/js.valueInvoke": (sp) => {
|
||||
sp >>>= 0;
|
||||
try {
|
||||
const v = loadValue(sp + 8);
|
||||
const args = loadSliceOfValues(sp + 16);
|
||||
const result = Reflect.apply(v, undefined, args);
|
||||
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||
storeValue(sp + 40, result);
|
||||
this.mem.setUint8(sp + 48, 1);
|
||||
} catch (err) {
|
||||
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||
storeValue(sp + 40, err);
|
||||
this.mem.setUint8(sp + 48, 0);
|
||||
}
|
||||
},
|
||||
|
||||
// func valueNew(v ref, args []ref) (ref, bool)
|
||||
"syscall/js.valueNew": (sp) => {
|
||||
sp >>>= 0;
|
||||
try {
|
||||
const v = loadValue(sp + 8);
|
||||
const args = loadSliceOfValues(sp + 16);
|
||||
const result = Reflect.construct(v, args);
|
||||
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||
storeValue(sp + 40, result);
|
||||
this.mem.setUint8(sp + 48, 1);
|
||||
} catch (err) {
|
||||
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||
storeValue(sp + 40, err);
|
||||
this.mem.setUint8(sp + 48, 0);
|
||||
}
|
||||
},
|
||||
|
||||
// func valueLength(v ref) int
|
||||
"syscall/js.valueLength": (sp) => {
|
||||
sp >>>= 0;
|
||||
setInt64(sp + 16, parseInt(loadValue(sp + 8).length));
|
||||
},
|
||||
|
||||
// valuePrepareString(v ref) (ref, int)
|
||||
"syscall/js.valuePrepareString": (sp) => {
|
||||
sp >>>= 0;
|
||||
const str = encoder.encode(String(loadValue(sp + 8)));
|
||||
storeValue(sp + 16, str);
|
||||
setInt64(sp + 24, str.length);
|
||||
},
|
||||
|
||||
// valueLoadString(v ref, b []byte)
|
||||
"syscall/js.valueLoadString": (sp) => {
|
||||
sp >>>= 0;
|
||||
const str = loadValue(sp + 8);
|
||||
loadSlice(sp + 16).set(str);
|
||||
},
|
||||
|
||||
// func valueInstanceOf(v ref, t ref) bool
|
||||
"syscall/js.valueInstanceOf": (sp) => {
|
||||
sp >>>= 0;
|
||||
this.mem.setUint8(sp + 24, (loadValue(sp + 8) instanceof loadValue(sp + 16)) ? 1 : 0);
|
||||
},
|
||||
|
||||
// func copyBytesToGo(dst []byte, src ref) (int, bool)
|
||||
"syscall/js.copyBytesToGo": (sp) => {
|
||||
sp >>>= 0;
|
||||
const dst = loadSlice(sp + 8);
|
||||
const src = loadValue(sp + 32);
|
||||
if (!(src instanceof Uint8Array || src instanceof Uint8ClampedArray)) {
|
||||
this.mem.setUint8(sp + 48, 0);
|
||||
return;
|
||||
}
|
||||
const toCopy = src.subarray(0, dst.length);
|
||||
dst.set(toCopy);
|
||||
setInt64(sp + 40, toCopy.length);
|
||||
this.mem.setUint8(sp + 48, 1);
|
||||
},
|
||||
|
||||
// func copyBytesToJS(dst ref, src []byte) (int, bool)
|
||||
"syscall/js.copyBytesToJS": (sp) => {
|
||||
sp >>>= 0;
|
||||
const dst = loadValue(sp + 8);
|
||||
const src = loadSlice(sp + 16);
|
||||
if (!(dst instanceof Uint8Array || dst instanceof Uint8ClampedArray)) {
|
||||
this.mem.setUint8(sp + 48, 0);
|
||||
return;
|
||||
}
|
||||
const toCopy = src.subarray(0, dst.length);
|
||||
dst.set(toCopy);
|
||||
setInt64(sp + 40, toCopy.length);
|
||||
this.mem.setUint8(sp + 48, 1);
|
||||
},
|
||||
|
||||
"debug": (value) => {
|
||||
console.log(value);
|
||||
},
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async run(instance) {
|
||||
if (!(instance instanceof WebAssembly.Instance)) {
|
||||
throw new Error("Go.run: WebAssembly.Instance expected");
|
||||
}
|
||||
this._inst = instance;
|
||||
this.mem = new DataView(this._inst.exports.mem.buffer);
|
||||
this._values = [ // JS values that Go currently has references to, indexed by reference id
|
||||
NaN,
|
||||
0,
|
||||
null,
|
||||
true,
|
||||
false,
|
||||
globalThis,
|
||||
this,
|
||||
];
|
||||
this._goRefCounts = new Array(this._values.length).fill(Infinity); // number of references that Go has to a JS value, indexed by reference id
|
||||
this._ids = new Map([ // mapping from JS values to reference ids
|
||||
[0, 1],
|
||||
[null, 2],
|
||||
[true, 3],
|
||||
[false, 4],
|
||||
[globalThis, 5],
|
||||
[this, 6],
|
||||
]);
|
||||
this._idPool = []; // unused ids that have been garbage collected
|
||||
this.exited = false; // whether the Go program has exited
|
||||
|
||||
// Pass command line arguments and environment variables to WebAssembly by writing them to the linear memory.
|
||||
let offset = 4096;
|
||||
|
||||
const strPtr = (str) => {
|
||||
const ptr = offset;
|
||||
const bytes = encoder.encode(str + "\0");
|
||||
new Uint8Array(this.mem.buffer, offset, bytes.length).set(bytes);
|
||||
offset += bytes.length;
|
||||
if (offset % 8 !== 0) {
|
||||
offset += 8 - (offset % 8);
|
||||
}
|
||||
return ptr;
|
||||
};
|
||||
|
||||
const argc = this.argv.length;
|
||||
|
||||
const argvPtrs = [];
|
||||
this.argv.forEach((arg) => {
|
||||
argvPtrs.push(strPtr(arg));
|
||||
});
|
||||
argvPtrs.push(0);
|
||||
|
||||
const keys = Object.keys(this.env).sort();
|
||||
keys.forEach((key) => {
|
||||
argvPtrs.push(strPtr(`${key}=${this.env[key]}`));
|
||||
});
|
||||
argvPtrs.push(0);
|
||||
|
||||
const argv = offset;
|
||||
argvPtrs.forEach((ptr) => {
|
||||
this.mem.setUint32(offset, ptr, true);
|
||||
this.mem.setUint32(offset + 4, 0, true);
|
||||
offset += 8;
|
||||
});
|
||||
|
||||
// The linker guarantees global data starts from at least wasmMinDataAddr.
|
||||
// Keep in sync with cmd/link/internal/ld/data.go:wasmMinDataAddr.
|
||||
const wasmMinDataAddr = 4096 + 8192;
|
||||
if (offset >= wasmMinDataAddr) {
|
||||
throw new Error("total length of command line and environment variables exceeds limit");
|
||||
}
|
||||
|
||||
this._inst.exports.run(argc, argv);
|
||||
if (this.exited) {
|
||||
this._resolveExitPromise();
|
||||
}
|
||||
await this._exitPromise;
|
||||
}
|
||||
|
||||
_resume() {
|
||||
if (this.exited) {
|
||||
throw new Error("Go program has already exited");
|
||||
}
|
||||
this._inst.exports.resume();
|
||||
if (this.exited) {
|
||||
this._resolveExitPromise();
|
||||
}
|
||||
}
|
||||
|
||||
_makeFuncWrapper(id) {
|
||||
const go = this;
|
||||
return function () {
|
||||
const event = { id: id, this: this, args: arguments };
|
||||
go._pendingEvent = event;
|
||||
go._resume();
|
||||
return event.result;
|
||||
};
|
||||
}
|
||||
}
|
||||
})();
|
||||
24
go/cmd/examples/go-wasm-web/wwwroot/wasmboot.js
Normal file
24
go/cmd/examples/go-wasm-web/wwwroot/wasmboot.js
Normal file
@@ -0,0 +1,24 @@
|
||||
// bootstrap.js — boots the Go/Wasm client, and supports flash-free hot swaps.
|
||||
//
|
||||
// On first load the server has already rendered the page's HTML into #app and
|
||||
// the wasm app hydrates it (see wasm/main.go). The dev server's livereload
|
||||
// script hot-swaps a freshly built module WITHOUT a full page reload or a blank
|
||||
// flash: it calls __gowasmPrepare() to fetch + compile the new module while the
|
||||
// current page is still visible, then (in one synchronous step) __gowasmDispose()
|
||||
// to tear down the old instance and start() to run the new one.
|
||||
(function () {
|
||||
// prepare fetches + compiles the module and returns a SYNCHRONOUS start()
|
||||
// thunk. Separating the async work (network + compile) from start (which
|
||||
// renders synchronously) is what lets a swap avoid an intermediate blank #app.
|
||||
async function prepare() {
|
||||
const go = new Go();
|
||||
// cache:no-store so a hot swap always fetches the freshly built bytes.
|
||||
const resp = await fetch("/app.wasm", { cache: "no-store" });
|
||||
const result = await WebAssembly.instantiateStreaming(resp, go.importObject);
|
||||
return function start() { go.run(result.instance); }; // runs main() (renders), then parks on select{}
|
||||
}
|
||||
async function boot() { (await prepare())(); }
|
||||
window.__gowasmPrepare = prepare;
|
||||
window.__gowasmBoot = boot;
|
||||
boot();
|
||||
})();
|
||||
45
go/cmd/twcss/main.go
Normal file
45
go/cmd/twcss/main.go
Normal file
@@ -0,0 +1,45 @@
|
||||
// Command twcss compiles a Tailwind v4 stylesheet with kjol's native engine,
|
||||
// scanning explicit content globs for utility candidates. Unlike the app bundler
|
||||
// (which is wired to the frontend tree) it takes the entry, output, and content
|
||||
// globs as flags/args, so it works for markup authored in any language — used by
|
||||
// the go-wasm-web example, whose UI is written in Go.
|
||||
//
|
||||
// Usage (globs are relative to -base; pass "**" for a recursive walk):
|
||||
//
|
||||
// twcss -entry css/app.css -out wwwroot/app.css -base . 'webui/**/*.go' 'app/**/*.go'
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"kjol/bundler"
|
||||
)
|
||||
|
||||
func main() {
|
||||
entry := flag.String("entry", "", "path to the Tailwind entry stylesheet")
|
||||
out := flag.String("out", "", "output CSS path")
|
||||
base := flag.String("base", ".", "base dir the content globs are relative to")
|
||||
flag.Parse()
|
||||
|
||||
if *entry == "" || *out == "" {
|
||||
fmt.Fprintln(os.Stderr, "twcss: -entry and -out are required")
|
||||
os.Exit(2)
|
||||
}
|
||||
src, err := os.ReadFile(*entry)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "twcss:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
css, err := bundler.CompileTailwind(string(src), *base, flag.Args())
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "twcss:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := os.WriteFile(*out, []byte(css), 0o644); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "twcss:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("twcss: wrote %s (%d bytes)\n", *out, len(css))
|
||||
}
|
||||
214
go/cmd/wasmgen/main.go
Normal file
214
go/cmd/wasmgen/main.go
Normal file
@@ -0,0 +1,214 @@
|
||||
// Command wasmgen preprocesses component directives into glue code so that
|
||||
// server-component calls look identical to client-component calls, and routes
|
||||
// can opt into SSR with a tag.
|
||||
//
|
||||
// Directives (as doc comments on functions in the target package):
|
||||
//
|
||||
// //gowasm:page <path> [static] [layout=<name>]
|
||||
// a page factory `func(Deps) func() *vdom.VNode`.
|
||||
// `static` => the server SSRs that route;
|
||||
// `layout=` => wrap it in a named //gowasm:layout.
|
||||
// //gowasm:layout <name> a layout `func(Deps, *vdom.VNode) *vdom.VNode`.
|
||||
// //gowasm:server a server component `func() func() *vdom.VNode`.
|
||||
//
|
||||
// It emits three files in the package:
|
||||
// - routes.gen.go (neutral) Routes(Deps) + StaticPaths + RouteLayout/LayoutFor
|
||||
// - server.gen.go (native) init() registering each server component
|
||||
// - client.gen.go (wasm) a client stub per server component that
|
||||
// mounts it over /rsc
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"go/ast"
|
||||
"go/format"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"text/template"
|
||||
)
|
||||
|
||||
type page struct {
|
||||
Func string
|
||||
Path string
|
||||
Static bool
|
||||
Layout string // name of the //gowasm:layout wrapper (empty => default)
|
||||
}
|
||||
|
||||
type layoutDecl struct {
|
||||
Name string
|
||||
Func string
|
||||
}
|
||||
|
||||
type genData struct {
|
||||
Pages []page
|
||||
Servers []string
|
||||
Layouts []layoutDecl
|
||||
DefaultLayout string // func name used when a page declares no layout
|
||||
}
|
||||
|
||||
func main() {
|
||||
dir := "app"
|
||||
if len(os.Args) > 1 {
|
||||
dir = os.Args[1]
|
||||
}
|
||||
|
||||
fset := token.NewFileSet()
|
||||
pkgs, err := parser.ParseDir(fset, dir, func(fi os.FileInfo) bool {
|
||||
return !strings.HasSuffix(fi.Name(), ".gen.go")
|
||||
}, parser.ParseComments)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "wasmgen: parse:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
var data genData
|
||||
for _, pkg := range pkgs {
|
||||
for _, file := range pkg.Files {
|
||||
for _, decl := range file.Decls {
|
||||
fn, ok := decl.(*ast.FuncDecl)
|
||||
if !ok || fn.Doc == nil {
|
||||
continue
|
||||
}
|
||||
for _, c := range fn.Doc.List {
|
||||
line := strings.TrimSpace(strings.TrimPrefix(c.Text, "//"))
|
||||
switch {
|
||||
case strings.HasPrefix(line, "gowasm:page "):
|
||||
fields := strings.Fields(line[len("gowasm:page "):])
|
||||
if len(fields) == 0 {
|
||||
continue
|
||||
}
|
||||
p := page{Func: fn.Name.Name, Path: fields[0]}
|
||||
for _, f := range fields[1:] {
|
||||
switch {
|
||||
case f == "static":
|
||||
p.Static = true
|
||||
case strings.HasPrefix(f, "layout="):
|
||||
p.Layout = strings.TrimPrefix(f, "layout=")
|
||||
}
|
||||
}
|
||||
data.Pages = append(data.Pages, p)
|
||||
case strings.HasPrefix(line, "gowasm:layout "):
|
||||
fields := strings.Fields(line[len("gowasm:layout "):])
|
||||
if len(fields) == 0 {
|
||||
continue
|
||||
}
|
||||
data.Layouts = append(data.Layouts, layoutDecl{Name: fields[0], Func: fn.Name.Name})
|
||||
case line == "gowasm:server":
|
||||
data.Servers = append(data.Servers, fn.Name.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Slice(data.Pages, func(i, j int) bool { return data.Pages[i].Path < data.Pages[j].Path })
|
||||
sort.Strings(data.Servers)
|
||||
sort.Slice(data.Layouts, func(i, j int) bool { return data.Layouts[i].Name < data.Layouts[j].Name })
|
||||
|
||||
// Default layout: prefer one literally named "app", else the first declared.
|
||||
var defaultName string
|
||||
if len(data.Layouts) > 0 {
|
||||
data.DefaultLayout, defaultName = data.Layouts[0].Func, data.Layouts[0].Name
|
||||
for _, l := range data.Layouts {
|
||||
if l.Name == "app" {
|
||||
data.DefaultLayout, defaultName = l.Func, l.Name
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
for i := range data.Pages {
|
||||
if data.Pages[i].Layout == "" {
|
||||
data.Pages[i].Layout = defaultName
|
||||
}
|
||||
}
|
||||
|
||||
write(filepath.Join(dir, "routes.gen.go"), routesTmpl, data)
|
||||
write(filepath.Join(dir, "server.gen.go"), serverTmpl, data)
|
||||
write(filepath.Join(dir, "client.gen.go"), clientTmpl, data)
|
||||
|
||||
fmt.Printf("wasmgen: %d page(s), %d server component(s)\n", len(data.Pages), len(data.Servers))
|
||||
}
|
||||
|
||||
func write(path, tmpl string, data genData) {
|
||||
var b strings.Builder
|
||||
if err := template.Must(template.New("g").Parse(tmpl)).Execute(&b, data); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
src, err := format.Source([]byte(b.String()))
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "wasmgen: format %s: %v\n---\n%s\n", path, err, b.String())
|
||||
os.Exit(1)
|
||||
}
|
||||
// Only write when changed, so re-running codegen doesn't churn mtimes (which
|
||||
// would make the dev server's file watcher loop).
|
||||
if existing, err := os.ReadFile(path); err == nil && string(existing) == string(src) {
|
||||
return
|
||||
}
|
||||
if err := os.WriteFile(path, src, 0o644); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
const routesTmpl = `// Code generated by wasmgen. DO NOT EDIT.
|
||||
package app
|
||||
|
||||
import "kjol/vdom"
|
||||
|
||||
// Routes maps each //gowasm:page path to its instantiated render function.
|
||||
func Routes(d Deps) map[string]func() *vdom.VNode {
|
||||
return map[string]func() *vdom.VNode{
|
||||
{{range .Pages}} "{{.Path}}": {{.Func}}(d),
|
||||
{{end}} }
|
||||
}
|
||||
|
||||
// StaticPaths are the routes the server pre-renders (SSR); others render client-side.
|
||||
var StaticPaths = map[string]bool{
|
||||
{{range .Pages}}{{if .Static}} "{{.Path}}": true,
|
||||
{{end}}{{end}}}
|
||||
|
||||
// RouteLayout maps each route to the name of the layout that wraps it.
|
||||
var RouteLayout = map[string]string{
|
||||
{{range .Pages}} "{{.Path}}": "{{.Layout}}",
|
||||
{{end}}}
|
||||
|
||||
// LayoutFor wraps a page's content in the layout declared for its route.
|
||||
func LayoutFor(d Deps, path string, content *vdom.VNode) *vdom.VNode {
|
||||
{{if .Layouts}} switch RouteLayout[path] {
|
||||
{{range .Layouts}} case "{{.Name}}":
|
||||
return {{.Func}}(d, content)
|
||||
{{end}} }
|
||||
return {{.DefaultLayout}}(d, content)
|
||||
{{else}} return content
|
||||
{{end}}}
|
||||
`
|
||||
|
||||
const serverTmpl = `// Code generated by wasmgen. DO NOT EDIT.
|
||||
|
||||
//go:build !(js && wasm)
|
||||
|
||||
package app
|
||||
|
||||
import "kjol/rsc"
|
||||
|
||||
func init() {
|
||||
{{range .Servers}} rsc.Register("{{.}}", {{.}})
|
||||
{{end}}}
|
||||
`
|
||||
|
||||
const clientTmpl = `// Code generated by wasmgen. DO NOT EDIT.
|
||||
|
||||
//go:build js && wasm
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"kjol/rsc"
|
||||
"kjol/vdom"
|
||||
)
|
||||
|
||||
{{range .Servers}}// {{.}} is a generated client stub for the server component of the same name.
|
||||
func {{.}}() func() *vdom.VNode { return rsc.Mount("{{.}}") }
|
||||
{{end}}`
|
||||
66
go/httputil/fetch.go
Normal file
66
go/httputil/fetch.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package httputil
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/gob"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
)
|
||||
|
||||
// Client-side fetch helpers — the counterparts to RespondGob / RespondJSON. In a
|
||||
// gowasm app the server and the WebAssembly client share the same Go types, so
|
||||
// the server encodes with RespondGob(data) and the client decodes straight back
|
||||
// into that type with FetchGob — no JSON, no hand-written unmarshalling. These
|
||||
// are neutral (they only touch encoding/*), so app code can call them while
|
||||
// staying SSR-able; the actual HTTP transport is client-only and injected at
|
||||
// startup by the wasm runtime (SetClientTransport), the inversion vdom.Schedule
|
||||
// uses.
|
||||
|
||||
// clientTransport does an HTTP GET returning the raw body; nil on the server.
|
||||
var clientTransport func(url string) ([]byte, error)
|
||||
|
||||
// SetClientTransport installs the HTTP GET used by FetchGob / FetchJSON. The
|
||||
// wasm client runtime calls this at startup with wasmruntime.FetchBytes.
|
||||
func SetClientTransport(get func(url string) ([]byte, error)) { clientTransport = get }
|
||||
|
||||
func fetchInto[T any](url string, cb func(T, error), decode func([]byte, *T) error) {
|
||||
go func() {
|
||||
var out T
|
||||
if clientTransport == nil {
|
||||
cb(out, errors.New("httputil: no client transport installed (client-only)"))
|
||||
return
|
||||
}
|
||||
body, err := clientTransport(url)
|
||||
if err != nil {
|
||||
cb(out, err)
|
||||
return
|
||||
}
|
||||
if err := decode(body, &out); err != nil {
|
||||
cb(out, err)
|
||||
return
|
||||
}
|
||||
cb(out, nil)
|
||||
}()
|
||||
}
|
||||
|
||||
// FetchGob GETs url and gob-decodes the body into T (pair with RespondGob), then
|
||||
// calls cb(result, err) on a background goroutine — safe to call from a render
|
||||
// or event handler; set the result into a signal in the callback. T is inferred
|
||||
// from the callback:
|
||||
//
|
||||
// httputil.FetchGob("/api/quotes", func(qs []Quote, err error) {
|
||||
// if err != nil { /* … */ } else { quotes.Set(qs) }
|
||||
// })
|
||||
func FetchGob[T any](url string, cb func(T, error)) {
|
||||
fetchInto(url, cb, func(b []byte, out *T) error {
|
||||
return gob.NewDecoder(bytes.NewReader(b)).Decode(out)
|
||||
})
|
||||
}
|
||||
|
||||
// FetchJSON GETs url and json-decodes the body into T — for third-party JSON
|
||||
// APIs (or your own RespondJSON). Same callback/goroutine shape as FetchGob.
|
||||
func FetchJSON[T any](url string, cb func(T, error)) {
|
||||
fetchInto(url, cb, func(b []byte, out *T) error {
|
||||
return json.Unmarshal(b, out)
|
||||
})
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package httputil
|
||||
|
||||
import (
|
||||
"encoding/gob"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
@@ -11,6 +12,12 @@ func RespondJSON(w http.ResponseWriter, statusCode int, data any) {
|
||||
json.NewEncoder(w).Encode(data)
|
||||
}
|
||||
|
||||
func RespondGob(w http.ResponseWriter, statusCode int, data any) {
|
||||
w.Header().Set("Content-Type", "application/gob")
|
||||
w.WriteHeader(statusCode)
|
||||
gob.NewEncoder(w).Encode(data)
|
||||
}
|
||||
|
||||
func RespondError(w http.ResponseWriter, statusCode int, message string) {
|
||||
RespondJSON(w, statusCode, map[string]string{"error": message})
|
||||
}
|
||||
|
||||
93
go/rsc/client_wasm.go
Normal file
93
go/rsc/client_wasm.go
Normal file
@@ -0,0 +1,93 @@
|
||||
//go:build js && wasm
|
||||
|
||||
package rsc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/gob"
|
||||
"syscall/js"
|
||||
|
||||
"kjol/vdom"
|
||||
)
|
||||
|
||||
// Mount connects to a server component by name and returns a render function.
|
||||
// The generated client stub for a //gowasm:server component just calls this, so
|
||||
// the call site is identical to a client component. It POSTs {name, state,
|
||||
// event} to /rsc and swaps (reconciles) the returned render into the DOM. State
|
||||
// is opaque and round-trips through the client — the server keeps nothing.
|
||||
func Mount(name string) func() *vdom.VNode {
|
||||
tree := vdom.NewSignal[*vdom.VNode](nil)
|
||||
var state [][]byte
|
||||
var post func(kind string, nodeID int, event, value string)
|
||||
|
||||
var convert func(*SNode) *vdom.VNode
|
||||
convert = func(sn *SNode) *vdom.VNode {
|
||||
if sn == nil {
|
||||
return nil
|
||||
}
|
||||
if sn.Tag == "" {
|
||||
return vdom.Text(sn.Text)
|
||||
}
|
||||
mods := make([]vdom.Mod, 0, len(sn.Attrs)+len(sn.Events)+len(sn.Kids)+1)
|
||||
for k, v := range sn.Attrs {
|
||||
mods = append(mods, vdom.Attr(k, v))
|
||||
}
|
||||
if sn.HTML != "" {
|
||||
mods = append(mods, vdom.Raw(sn.HTML))
|
||||
}
|
||||
for _, ev := range sn.Events {
|
||||
event, id := ev, sn.ID
|
||||
mods = append(mods, vdom.OnEvent(ev, func(e vdom.Event) { post("event", id, event, e.Value()) }))
|
||||
}
|
||||
for _, k := range sn.Kids {
|
||||
if c := convert(k); c != nil {
|
||||
mods = append(mods, c)
|
||||
}
|
||||
}
|
||||
return vdom.El(sn.Tag, mods...)
|
||||
}
|
||||
|
||||
onArr := js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||
arr := js.Global().Get("Uint8Array").New(args[0])
|
||||
b := make([]byte, arr.Get("length").Int())
|
||||
js.CopyBytesToGo(b, arr)
|
||||
var resp Response
|
||||
if gob.NewDecoder(bytes.NewReader(b)).Decode(&resp) == nil {
|
||||
state = resp.State
|
||||
tree.Set(convert(resp.Tree)) // re-render -> reconcile into the DOM
|
||||
}
|
||||
return nil
|
||||
})
|
||||
onResp := js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||
args[0].Call("arrayBuffer").Call("then", onArr)
|
||||
return nil
|
||||
})
|
||||
|
||||
loc := js.Global().Get("location")
|
||||
url := loc.Get("protocol").String() + "//" + loc.Get("host").String() + "/rsc"
|
||||
|
||||
post = func(kind string, nodeID int, event, value string) {
|
||||
var buf bytes.Buffer
|
||||
if gob.NewEncoder(&buf).Encode(Request{Kind: kind, Name: name, State: state, NodeID: nodeID, Event: event, Value: value}) != nil {
|
||||
return
|
||||
}
|
||||
body := js.Global().Get("Uint8Array").New(buf.Len())
|
||||
js.CopyBytesToJS(body, buf.Bytes())
|
||||
opts := js.Global().Get("Object").New()
|
||||
opts.Set("method", "POST")
|
||||
opts.Set("body", body)
|
||||
js.Global().Call("fetch", url, opts).Call("then", onResp)
|
||||
}
|
||||
|
||||
started := false
|
||||
return func() *vdom.VNode {
|
||||
if !started {
|
||||
started = true
|
||||
post("mount", 0, "", "")
|
||||
}
|
||||
if t := tree.Get(); t != nil {
|
||||
return t
|
||||
}
|
||||
return vdom.Div(vdom.Attr("class", "text-muted"), vdom.Text("loading server component…"))
|
||||
}
|
||||
}
|
||||
38
go/rsc/proto.go
Normal file
38
go/rsc/proto.go
Normal file
@@ -0,0 +1,38 @@
|
||||
// Package rsc runs server components with a stateless, React/Next-style
|
||||
// request/response: the client POSTs the component name, its (opaque) signal
|
||||
// state, and any triggered event; the server restores the state, applies the
|
||||
// event, re-renders, and returns the new state + rendered tree, which the client
|
||||
// reconciles ("swaps") into the DOM. No persistent connection, no server-held
|
||||
// session — state round-trips through the client.
|
||||
//
|
||||
// This file holds the wire types (gob), shared by server and client.
|
||||
package rsc
|
||||
|
||||
// SNode is a serialized render node. Nodes with server handlers carry an ID and
|
||||
// their event names; the client sends {name, state, ID, event} back so the
|
||||
// server can find and invoke the handler.
|
||||
type SNode struct {
|
||||
ID int
|
||||
Tag string
|
||||
Text string
|
||||
HTML string
|
||||
Attrs map[string]string
|
||||
Events []string
|
||||
Kids []*SNode
|
||||
}
|
||||
|
||||
// Request is client -> server (every request is self-contained).
|
||||
type Request struct {
|
||||
Kind string // "mount" | "event"
|
||||
Name string // component name
|
||||
State [][]byte // opaque signal snapshot from the previous response
|
||||
NodeID int // event: node that fired
|
||||
Event string // event: event name
|
||||
Value string // event: target value (inputs)
|
||||
}
|
||||
|
||||
// Response is server -> client.
|
||||
type Response struct {
|
||||
State [][]byte
|
||||
Tree *SNode
|
||||
}
|
||||
95
go/rsc/server.go
Normal file
95
go/rsc/server.go
Normal file
@@ -0,0 +1,95 @@
|
||||
//go:build !(js && wasm)
|
||||
|
||||
package rsc
|
||||
|
||||
import (
|
||||
"encoding/gob"
|
||||
"net/http"
|
||||
"sort"
|
||||
"sync"
|
||||
|
||||
"kjol/vdom"
|
||||
)
|
||||
|
||||
// registry maps a server-component name to its factory (created by generated
|
||||
// code — one Register per //gowasm:server component). The factory builds the
|
||||
// component's signals + render closure; here it's re-run per request (stateless).
|
||||
var registry = map[string]func() func() *vdom.VNode{}
|
||||
|
||||
func Register(name string, factory func() func() *vdom.VNode) { registry[name] = factory }
|
||||
|
||||
// renderMu serializes server renders because the vdom signal Collector is a
|
||||
// process-global (see vdom.BeginCollect). Fine for this scale.
|
||||
var renderMu sync.Mutex
|
||||
|
||||
// Handler is the single /rsc endpoint. It restores the component's signals from
|
||||
// the request, applies the event (if any), re-renders, and returns the new
|
||||
// signal snapshot + tree.
|
||||
func Handler(w http.ResponseWriter, r *http.Request) {
|
||||
var req Request
|
||||
if gob.NewDecoder(r.Body).Decode(&req) != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
factory := registry[req.Name]
|
||||
if factory == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
renderMu.Lock()
|
||||
c := vdom.BeginCollect(req.State) // restore prior signal values (nil on mount)
|
||||
render := factory() // signals created + restored here
|
||||
vdom.EndCollect()
|
||||
|
||||
if req.Kind == "event" {
|
||||
// Reproduce the tree the client currently shows (same state) to find the
|
||||
// handler by node id, then invoke it (mutating signals).
|
||||
_, handlers := renderIDs(render())
|
||||
if hm := handlers[req.NodeID]; hm != nil {
|
||||
if h := hm[req.Event]; h != nil {
|
||||
h(serverEvent{value: req.Value})
|
||||
}
|
||||
}
|
||||
}
|
||||
tree, _ := renderIDs(render()) // render the result (reflects the mutation)
|
||||
resp := Response{State: c.Snapshot(), Tree: tree}
|
||||
renderMu.Unlock()
|
||||
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
_ = gob.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
// renderIDs serializes a VNode tree to SNode, assigning a stable (traversal
|
||||
// order) id to each node with handlers and building the handler table.
|
||||
func renderIDs(root *vdom.VNode) (*SNode, map[int]map[string]func(vdom.Event)) {
|
||||
handlers := map[int]map[string]func(vdom.Event){}
|
||||
id := 0
|
||||
var walk func(*vdom.VNode) *SNode
|
||||
walk = func(v *vdom.VNode) *SNode {
|
||||
sn := &SNode{Tag: v.Tag, Text: v.Text, HTML: v.HTML, Attrs: v.Attrs}
|
||||
if len(v.Events) > 0 {
|
||||
id++
|
||||
sn.ID = id
|
||||
hm := map[string]func(vdom.Event){}
|
||||
for ev, h := range v.Events {
|
||||
sn.Events = append(sn.Events, ev)
|
||||
hm[ev] = h
|
||||
}
|
||||
sort.Strings(sn.Events)
|
||||
handlers[id] = hm
|
||||
}
|
||||
if v.HTML == "" {
|
||||
for _, k := range v.Children {
|
||||
sn.Kids = append(sn.Kids, walk(k))
|
||||
}
|
||||
}
|
||||
return sn
|
||||
}
|
||||
return walk(root), handlers
|
||||
}
|
||||
|
||||
type serverEvent struct{ value string }
|
||||
|
||||
func (e serverEvent) PreventDefault() {}
|
||||
func (e serverEvent) Value() string { return e.value }
|
||||
17
go/vdom/events.go
Normal file
17
go/vdom/events.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package vdom
|
||||
|
||||
// DOM event-name constants for On / OnEvent.
|
||||
const (
|
||||
EVENT_CLICK = "click"
|
||||
EVENT_DBLCLICK = "dblclick"
|
||||
EVENT_INPUT = "input"
|
||||
EVENT_CHANGE = "change"
|
||||
EVENT_SUBMIT = "submit"
|
||||
EVENT_KEYDOWN = "keydown"
|
||||
EVENT_KEYUP = "keyup"
|
||||
EVENT_FOCUS = "focus"
|
||||
EVENT_BLUR = "blur"
|
||||
EVENT_MOUSEDOWN = "mousedown"
|
||||
EVENT_MOUSEUP = "mouseup"
|
||||
EVENT_MOUSEMOVE = "mousemove"
|
||||
)
|
||||
7
go/vdom/mode_native.go
Normal file
7
go/vdom/mode_native.go
Normal file
@@ -0,0 +1,7 @@
|
||||
//go:build !(js && wasm)
|
||||
|
||||
package vdom
|
||||
|
||||
// IsClient is false on the server (native). Components use it to guard
|
||||
// client-only effects (e.g. fetching) so they don't run during SSR.
|
||||
const IsClient = false
|
||||
6
go/vdom/mode_wasm.go
Normal file
6
go/vdom/mode_wasm.go
Normal file
@@ -0,0 +1,6 @@
|
||||
//go:build js && wasm
|
||||
|
||||
package vdom
|
||||
|
||||
// IsClient is true in the browser (wasm).
|
||||
const IsClient = true
|
||||
81
go/vdom/signal.go
Normal file
81
go/vdom/signal.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package vdom
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// Schedule is installed by the wasm runtime at startup; a signal write
|
||||
// triggers a re-render. On the server it is nil (renders are one-shot).
|
||||
var Schedule func()
|
||||
|
||||
// Signal holds state. On the client, writing it schedules a re-render.
|
||||
type Signal[T any] struct{ v T }
|
||||
|
||||
func NewSignal[T any](initial T) *Signal[T] {
|
||||
s := &Signal[T]{v: initial}
|
||||
if active != nil {
|
||||
active.adopt(s) // server component round-trip: restore prior value, track for snapshot
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *Signal[T]) Get() T { return s.v }
|
||||
|
||||
func (s *Signal[T]) Set(v T) {
|
||||
s.v = v
|
||||
if Schedule != nil {
|
||||
Schedule()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Signal[T]) Update(fn func(T) T) { s.Set(fn(s.v)) }
|
||||
|
||||
func (s *Signal[T]) snapshot() []byte { b, _ := json.Marshal(s.v); return b }
|
||||
func (s *Signal[T]) restoreFrom(b []byte) { _ = json.Unmarshal(b, &s.v) }
|
||||
|
||||
// ---- signal-state round-trip for server components ----
|
||||
//
|
||||
// A server component is stateless on the server: its signal values are
|
||||
// serialized and round-tripped through the client. While a Collector is active
|
||||
// (during a server-component render on the server), NewSignal restores each
|
||||
// signal's value from the incoming snapshot by creation order (hook-order), and
|
||||
// remembers it so the new values can be snapshotted back out.
|
||||
|
||||
type signalState interface {
|
||||
snapshot() []byte
|
||||
restoreFrom([]byte)
|
||||
}
|
||||
|
||||
// Collector captures the signals created during a server render.
|
||||
type Collector struct {
|
||||
restore [][]byte
|
||||
idx int
|
||||
sigs []signalState
|
||||
}
|
||||
|
||||
var active *Collector
|
||||
|
||||
func (c *Collector) adopt(s signalState) {
|
||||
if c.idx < len(c.restore) {
|
||||
s.restoreFrom(c.restore[c.idx])
|
||||
}
|
||||
c.idx++
|
||||
c.sigs = append(c.sigs, s)
|
||||
}
|
||||
|
||||
// BeginCollect starts collecting signals, restoring them from `restore` (which
|
||||
// may be nil for an initial mount). Call EndCollect when construction is done.
|
||||
func BeginCollect(restore [][]byte) *Collector {
|
||||
active = &Collector{restore: restore}
|
||||
return active
|
||||
}
|
||||
|
||||
// EndCollect stops collecting (rendering/handlers may still read the signals).
|
||||
func EndCollect() { active = nil }
|
||||
|
||||
// Snapshot returns the current values of the collected signals, in order.
|
||||
func (c *Collector) Snapshot() [][]byte {
|
||||
out := make([][]byte, len(c.sigs))
|
||||
for i, s := range c.sigs {
|
||||
out[i] = s.snapshot()
|
||||
}
|
||||
return out
|
||||
}
|
||||
30
go/vdom/tags.go
Normal file
30
go/vdom/tags.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package vdom
|
||||
|
||||
// HTML tag helpers over El, for dot-import.
|
||||
func Div(m ...Mod) *VNode { return El("div", m...) }
|
||||
func Span(m ...Mod) *VNode { return El("span", m...) }
|
||||
func P(m ...Mod) *VNode { return El("p", m...) }
|
||||
func Section(m ...Mod) *VNode { return El("section", m...) }
|
||||
func Nav(m ...Mod) *VNode { return El("nav", m...) }
|
||||
func Header(m ...Mod) *VNode { return El("header", m...) }
|
||||
func Main(m ...Mod) *VNode { return El("main", m...) }
|
||||
func Footer(m ...Mod) *VNode { return El("footer", m...) }
|
||||
func H1(m ...Mod) *VNode { return El("h1", m...) }
|
||||
func H2(m ...Mod) *VNode { return El("h2", m...) }
|
||||
func H3(m ...Mod) *VNode { return El("h3", m...) }
|
||||
func Hr(m ...Mod) *VNode { return El("hr", m...) }
|
||||
func A(m ...Mod) *VNode { return El("a", m...) }
|
||||
func Strong(m ...Mod) *VNode { return El("strong", m...) }
|
||||
func Em(m ...Mod) *VNode { return El("em", m...) }
|
||||
func Small(m ...Mod) *VNode { return El("small", m...) }
|
||||
func Code(m ...Mod) *VNode { return El("code", m...) }
|
||||
func Label(m ...Mod) *VNode { return El("label", m...) }
|
||||
func Ul(m ...Mod) *VNode { return El("ul", m...) }
|
||||
func Li(m ...Mod) *VNode { return El("li", m...) }
|
||||
func Form(m ...Mod) *VNode { return El("form", m...) }
|
||||
func Input(m ...Mod) *VNode { return El("input", m...) }
|
||||
func Button(m ...Mod) *VNode { return El("button", m...) }
|
||||
func Table(m ...Mod) *VNode { return El("table", m...) }
|
||||
func Tr(m ...Mod) *VNode { return El("tr", m...) }
|
||||
func Td(m ...Mod) *VNode { return El("td", m...) }
|
||||
func Img(m ...Mod) *VNode { return El("img", m...) }
|
||||
142
go/vdom/vnode.go
Normal file
142
go/vdom/vnode.go
Normal file
@@ -0,0 +1,142 @@
|
||||
// Package vdom is a platform-neutral virtual DOM shared by the server (renders
|
||||
// to an HTML string) and the client (reconciles into the real DOM). It compiles
|
||||
// on BOTH native and js/wasm, so the same component code runs in both places —
|
||||
// which is what makes server-side rendering + client hydration possible.
|
||||
package vdom
|
||||
|
||||
import (
|
||||
"html"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Event is a DOM event passed to handlers. The client provides a concrete
|
||||
// implementation; on the server events are never invoked.
|
||||
type Event interface {
|
||||
PreventDefault()
|
||||
Value() string // target.value (for inputs)
|
||||
}
|
||||
|
||||
// VNode is a virtual DOM node. Tag == "" is a text node (content in Text). HTML,
|
||||
// if set on an element, is raw innerHTML (children ignored).
|
||||
type VNode struct {
|
||||
Tag string
|
||||
Text string
|
||||
HTML string
|
||||
Attrs map[string]string
|
||||
Props map[string]string
|
||||
Events map[string]func(Event)
|
||||
Children []*VNode
|
||||
|
||||
// Runtime holds the wasm reconciler's per-node bookkeeping (DOM handle,
|
||||
// listener wrappers). It's `any` so this package stays platform-neutral; it
|
||||
// is nil on the server.
|
||||
Runtime any
|
||||
}
|
||||
|
||||
// Mod configures a VNode while it is built.
|
||||
type Mod interface{ apply(*VNode) }
|
||||
|
||||
// El builds an element VNode.
|
||||
func El(tag string, mods ...Mod) *VNode {
|
||||
n := &VNode{Tag: tag, Attrs: map[string]string{}, Props: map[string]string{}, Events: map[string]func(Event){}}
|
||||
for _, m := range mods {
|
||||
m.apply(n)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// Text builds a text VNode.
|
||||
func Text(s string) *VNode { return &VNode{Text: s} }
|
||||
|
||||
func (n *VNode) apply(parent *VNode) {
|
||||
if n != nil { // a nil child renders nothing (e.g. a closed Modal/Menu returns nil)
|
||||
parent.Children = append(parent.Children, n)
|
||||
}
|
||||
}
|
||||
|
||||
type attrMod struct{ k, v string }
|
||||
|
||||
func (a attrMod) apply(n *VNode) { n.Attrs[a.k] = a.v }
|
||||
|
||||
// Attr sets an HTML attribute.
|
||||
func Attr(k, v string) Mod { return attrMod{k, v} }
|
||||
|
||||
type propMod struct{ k, v string }
|
||||
|
||||
func (p propMod) apply(n *VNode) { n.Props[p.k] = p.v }
|
||||
|
||||
// Prop sets a live DOM property (e.g. an input's value).
|
||||
func Prop(k, v string) Mod { return propMod{k, v} }
|
||||
|
||||
type htmlMod struct{ html string }
|
||||
|
||||
func (h htmlMod) apply(n *VNode) { n.HTML = h.html }
|
||||
|
||||
// Raw sets inner HTML verbatim (children ignored) — e.g. a server-computed SVG.
|
||||
func Raw(html string) Mod { return htmlMod{html} }
|
||||
|
||||
type eventMod struct {
|
||||
name string
|
||||
h func(Event)
|
||||
}
|
||||
|
||||
func (e eventMod) apply(n *VNode) { n.Events[e.name] = e.h }
|
||||
|
||||
// On registers an event handler that ignores the event object.
|
||||
func On(event string, h func()) Mod { return eventMod{event, func(Event) { h() }} }
|
||||
|
||||
// OnEvent registers an event handler that receives the Event.
|
||||
func OnEvent(event string, h func(Event)) Mod { return eventMod{event, h} }
|
||||
|
||||
// --- server-side HTML rendering (used for SSR; runs on any platform) ---
|
||||
|
||||
var voidTags = map[string]bool{"br": true, "hr": true, "img": true, "input": true, "meta": true, "link": true}
|
||||
|
||||
// RenderHTML serializes a VNode tree to HTML with NO extra whitespace, so the
|
||||
// browser's parsed childNodes line up 1:1 with the VNode children on hydration.
|
||||
func RenderHTML(n *VNode) string {
|
||||
var b strings.Builder
|
||||
writeNode(&b, n)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func writeNode(b *strings.Builder, n *VNode) {
|
||||
if n.Tag == "" {
|
||||
b.WriteString(html.EscapeString(n.Text))
|
||||
return
|
||||
}
|
||||
b.WriteByte('<')
|
||||
b.WriteString(n.Tag)
|
||||
writeAttrs(b, n.Attrs)
|
||||
writeAttrs(b, n.Props) // props like input value show up as attributes in SSR
|
||||
b.WriteByte('>')
|
||||
if voidTags[n.Tag] {
|
||||
return
|
||||
}
|
||||
if n.HTML != "" {
|
||||
b.WriteString(n.HTML) // raw
|
||||
} else {
|
||||
for _, c := range n.Children {
|
||||
writeNode(b, c)
|
||||
}
|
||||
}
|
||||
b.WriteString("</")
|
||||
b.WriteString(n.Tag)
|
||||
b.WriteByte('>')
|
||||
}
|
||||
|
||||
func writeAttrs(b *strings.Builder, m map[string]string) {
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, k := range keys {
|
||||
b.WriteByte(' ')
|
||||
b.WriteString(k)
|
||||
b.WriteString(`="`)
|
||||
b.WriteString(html.EscapeString(m[k]))
|
||||
b.WriteByte('"')
|
||||
}
|
||||
}
|
||||
376
go/wasmdevserver/devserver.go
Normal file
376
go/wasmdevserver/devserver.go
Normal file
@@ -0,0 +1,376 @@
|
||||
// Package wasmdevserver is a reusable development server for gowasm apps: it serves
|
||||
// the built web assets, renders routes server-side (SSR) at request time, hosts
|
||||
// the /rsc server-component endpoint, and hot-swaps the freshly built wasm into
|
||||
// the browser on change (no full reload, state preserved) — surfacing build
|
||||
// failures as an in-page overlay.
|
||||
//
|
||||
// It never imports application code (per kjol's golden rule). The app injects
|
||||
// everything specific to it through Config: how to build the wasm bundle
|
||||
// (Build), how to render a route to HTML (Render), and how to wrap that HTML in
|
||||
// a document (Document). The WebSocket hub and file watcher use only the
|
||||
// standard library.
|
||||
package wasmdevserver
|
||||
|
||||
import (
|
||||
"crypto/sha1"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"kjol/rsc"
|
||||
)
|
||||
|
||||
// Config wires an app into the dev server. Render and Document are called per
|
||||
// request; Build is called for the initial build and on every source change.
|
||||
type Config struct {
|
||||
Addr string // listen address (default ":8085")
|
||||
Dir string // static assets dir; Build writes app.wasm here (default "./wwwroot")
|
||||
Watch bool // rebuild on change + hot reload
|
||||
WatchDirs []string // source dirs to watch when Watch is set
|
||||
Build func() ([]byte, error) // (re)build the wasm bundle; combined output on failure
|
||||
Render func(path string) (inner string, ok bool) // SSR the #app inner HTML for a route (ok=false => client-rendered)
|
||||
Document func(inner string) string // wrap #app inner HTML in a full HTML document
|
||||
Handle func(mux *http.ServeMux) // optional: register extra routes (e.g. app API endpoints)
|
||||
}
|
||||
|
||||
// Serve builds once (in watch mode), wires the routes, and blocks serving.
|
||||
func Serve(cfg Config) error {
|
||||
if cfg.Addr == "" {
|
||||
cfg.Addr = ":8085"
|
||||
}
|
||||
if cfg.Dir == "" {
|
||||
cfg.Dir = "./wwwroot"
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
h := newHub()
|
||||
|
||||
if cfg.Watch {
|
||||
if err := ensureShim(cfg.Dir); err != nil {
|
||||
log.Printf("warning: could not stage wasm_exec.js: %v", err)
|
||||
}
|
||||
if cfg.Build != nil {
|
||||
if out, err := cfg.Build(); err != nil {
|
||||
log.Printf("initial build failed: %v\n%s", err, out)
|
||||
h.setError(string(out)) // a browser opened now sees it via the overlay
|
||||
}
|
||||
}
|
||||
mux.HandleFunc("/livereload", h.serveWS)
|
||||
mux.HandleFunc("/livereload.js", serveClientJS)
|
||||
go watchLoop(cfg, h)
|
||||
log.Printf("hot reload enabled (watching %v)", cfg.WatchDirs)
|
||||
}
|
||||
|
||||
mux.HandleFunc("POST /rsc", rsc.Handler) // server components
|
||||
if cfg.Handle != nil {
|
||||
cfg.Handle(mux) // app-registered routes (API endpoints, etc.)
|
||||
}
|
||||
mux.HandleFunc("/", rootHandler(cfg))
|
||||
|
||||
log.Printf("serving %q on http://localhost%s", cfg.Dir, cfg.Addr)
|
||||
return http.ListenAndServe(cfg.Addr, mux)
|
||||
}
|
||||
|
||||
// ---- serving: assets, and dynamic SSR for HTML routes -------------------
|
||||
|
||||
func rootHandler(cfg Config) http.HandlerFunc {
|
||||
live := ""
|
||||
if cfg.Watch {
|
||||
live = `<script src="/livereload.js"></script>`
|
||||
}
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
clean := filepath.Clean("/" + r.URL.Path)
|
||||
fsPath := filepath.Join(cfg.Dir, clean)
|
||||
if info, err := os.Stat(fsPath); err == nil && !info.IsDir() && clean != "/" {
|
||||
if strings.HasSuffix(fsPath, ".wasm") {
|
||||
w.Header().Set("Content-Type", "application/wasm")
|
||||
}
|
||||
http.ServeFile(w, r, fsPath)
|
||||
return
|
||||
}
|
||||
inner := ""
|
||||
if cfg.Render != nil {
|
||||
inner, _ = cfg.Render(r.URL.Path)
|
||||
}
|
||||
html := inner
|
||||
if cfg.Document != nil {
|
||||
html = cfg.Document(inner)
|
||||
}
|
||||
if live != "" {
|
||||
html = strings.Replace(html, "</body>", live+"\n</body>", 1)
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
io.WriteString(w, html)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- build + watch ------------------------------------------------------
|
||||
|
||||
func watchLoop(cfg Config, h *hub) {
|
||||
prev := fingerprint(cfg.WatchDirs)
|
||||
for {
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
fp := fingerprint(cfg.WatchDirs)
|
||||
if fp == prev {
|
||||
continue
|
||||
}
|
||||
prev = fp
|
||||
log.Println("change detected, rebuilding…")
|
||||
h.broadcast(`{"type":"building"}`)
|
||||
if cfg.Build == nil {
|
||||
continue
|
||||
}
|
||||
if out, err := cfg.Build(); err != nil {
|
||||
log.Printf("build failed: %v\n%s", err, out)
|
||||
h.setError(string(out)) // push the compiler output to the browser overlay
|
||||
continue
|
||||
}
|
||||
log.Println("rebuild ok — reloading clients")
|
||||
h.clearError()
|
||||
h.broadcast(`{"type":"reload"}`)
|
||||
}
|
||||
}
|
||||
|
||||
// fingerprint changes whenever any .go file under dirs is modified.
|
||||
func fingerprint(dirs []string) int64 {
|
||||
var fp int64
|
||||
for _, d := range dirs {
|
||||
filepath.WalkDir(d, func(path string, e fs.DirEntry, err error) error {
|
||||
if err != nil || e.IsDir() || !strings.HasSuffix(path, ".go") {
|
||||
return nil
|
||||
}
|
||||
if info, err := e.Info(); err == nil {
|
||||
fp += info.ModTime().UnixNano() + info.Size()
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
return fp
|
||||
}
|
||||
|
||||
func jsonStr(s string) string { b, _ := json.Marshal(s); return string(b) }
|
||||
|
||||
// ensureShim copies Go's wasm_exec.js into dir if it isn't already there, so the
|
||||
// server is self-sufficient without a separate build step first.
|
||||
func ensureShim(dir string) error {
|
||||
dst := filepath.Join(dir, "wasm_exec.js")
|
||||
if _, err := os.Stat(dst); err == nil {
|
||||
return nil
|
||||
}
|
||||
root, err := exec.Command("go", "env", "GOROOT").Output()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
goroot := strings.TrimSpace(string(root))
|
||||
for _, p := range []string{
|
||||
filepath.Join(goroot, "lib", "wasm", "wasm_exec.js"), // Go >= 1.24
|
||||
filepath.Join(goroot, "misc", "wasm", "wasm_exec.js"), // Go <= 1.23
|
||||
} {
|
||||
if b, err := os.ReadFile(p); err == nil {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(dst, b, 0o644)
|
||||
}
|
||||
}
|
||||
return os.ErrNotExist
|
||||
}
|
||||
|
||||
// ---- injected livereload client -----------------------------------------
|
||||
|
||||
const clientJS = `// Injected by the dev server in watch mode.
|
||||
(function () {
|
||||
// Hot-swap the freshly built wasm in place — no full page reload and no blank
|
||||
// flash. We fetch + compile the NEW module while the current page stays
|
||||
// visible, then tear down the old instance and start the new one in the SAME
|
||||
// task, so the browser never paints the intermediate empty #app. State is
|
||||
// preserved: the old instance snapshots its signals on dispose; the new one
|
||||
// restores them on boot.
|
||||
async function hotSwap() {
|
||||
if (!window.__gowasmPrepare) { location.reload(); return; } // fallback
|
||||
var start;
|
||||
try { start = await window.__gowasmPrepare(); } // network + compile; page still visible
|
||||
catch (err) { console.error(err); location.reload(); return; }
|
||||
hideOverlay();
|
||||
try { if (window.__gowasmDispose) window.__gowasmDispose(); } catch (err) { console.error(err); }
|
||||
start(); // renders synchronously — no await between dispose and first paint
|
||||
}
|
||||
|
||||
// Full-screen overlay showing the Go compiler output when a build fails. The
|
||||
// app underneath keeps running (and its state), so fixing the code and saving
|
||||
// clears the overlay and hot-swaps without losing anything.
|
||||
function ensureOverlay() {
|
||||
var el = document.getElementById("__gowasm_error");
|
||||
if (!el) {
|
||||
el = document.createElement("div");
|
||||
el.id = "__gowasm_error";
|
||||
el.style.cssText = "position:fixed;inset:0;z-index:2147483647;margin:0;padding:24px 28px;" +
|
||||
"background:rgba(24,24,27,0.97);color:#e4e4e7;overflow:auto;" +
|
||||
"font:13px/1.55 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;";
|
||||
document.body.appendChild(el);
|
||||
}
|
||||
return el;
|
||||
}
|
||||
function showOverlay(text) {
|
||||
var el = ensureOverlay();
|
||||
el.textContent = "";
|
||||
var head = document.createElement("div");
|
||||
head.style.cssText = "color:#f87171;font-weight:700;font-size:15px;margin-bottom:14px;";
|
||||
head.textContent = "⚠ gowasm — build failed";
|
||||
var pre = document.createElement("pre");
|
||||
pre.style.cssText = "margin:0;white-space:pre-wrap;word-break:break-word;";
|
||||
pre.textContent = text || "(no compiler output)";
|
||||
el.appendChild(head);
|
||||
el.appendChild(pre);
|
||||
}
|
||||
function hideOverlay() {
|
||||
var el = document.getElementById("__gowasm_error");
|
||||
if (el && el.parentNode) { el.parentNode.removeChild(el); }
|
||||
}
|
||||
window.__gowasmErrorOverlay = { show: showOverlay, hide: hideOverlay };
|
||||
|
||||
function connect() {
|
||||
var proto = location.protocol === "https:" ? "wss://" : "ws://";
|
||||
var ws = new WebSocket(proto + location.host + "/livereload");
|
||||
ws.onmessage = function (e) {
|
||||
var msg = {};
|
||||
try { msg = JSON.parse(e.data); } catch (_) { return; }
|
||||
if (msg.type === "reload") { hotSwap(); } // build ok: swap in place
|
||||
else if (msg.type === "error") { showOverlay(msg.msg); } // build failed: show compiler output
|
||||
else if (msg.type === "building") { console.log("[hot reload] rebuilding…"); }
|
||||
};
|
||||
ws.onclose = function () { setTimeout(connect, 1000); }; // reconnect after reload/restart
|
||||
ws.onerror = function () { try { ws.close(); } catch (_) {} };
|
||||
}
|
||||
connect();
|
||||
})();
|
||||
`
|
||||
|
||||
func serveClientJS(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/javascript; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
io.WriteString(w, clientJS)
|
||||
}
|
||||
|
||||
// ---- minimal WebSocket hub (stdlib only) --------------------------------
|
||||
|
||||
type hub struct {
|
||||
mu sync.Mutex
|
||||
clients map[*wsConn]struct{}
|
||||
lastError string // most recent build-failure message (JSON), replayed to new clients
|
||||
}
|
||||
|
||||
func newHub() *hub { return &hub{clients: map[*wsConn]struct{}{}} }
|
||||
|
||||
func (h *hub) add(c *wsConn) { h.mu.Lock(); h.clients[c] = struct{}{}; h.mu.Unlock() }
|
||||
func (h *hub) remove(c *wsConn) { h.mu.Lock(); delete(h.clients, c); h.mu.Unlock() }
|
||||
|
||||
// setError records the current build failure (so it survives to new clients) and
|
||||
// pushes it to everyone connected. clearError is called on the next good build.
|
||||
func (h *hub) setError(out string) {
|
||||
msg := `{"type":"error","msg":` + jsonStr(out) + `}`
|
||||
h.mu.Lock()
|
||||
h.lastError = msg
|
||||
h.mu.Unlock()
|
||||
h.broadcast(msg)
|
||||
}
|
||||
|
||||
func (h *hub) clearError() { h.mu.Lock(); h.lastError = ""; h.mu.Unlock() }
|
||||
|
||||
func (h *hub) errorMessage() string { h.mu.Lock(); defer h.mu.Unlock(); return h.lastError }
|
||||
|
||||
func (h *hub) broadcast(msg string) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
for c := range h.clients {
|
||||
if err := c.sendText(msg); err != nil {
|
||||
c.conn.Close()
|
||||
delete(h.clients, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// serveWS upgrades the request to a WebSocket and keeps the connection until the
|
||||
// client disconnects. We only ever push server->client, so incoming frames are
|
||||
// drained (which also lets us detect disconnects).
|
||||
func (h *hub) serveWS(w http.ResponseWriter, r *http.Request) {
|
||||
if !strings.Contains(strings.ToLower(r.Header.Get("Upgrade")), "websocket") {
|
||||
http.Error(w, "expected websocket upgrade", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
hj, ok := w.(http.Hijacker)
|
||||
if !ok {
|
||||
http.Error(w, "hijacking unsupported", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
conn, brw, err := hj.Hijack()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
accept := acceptKey(r.Header.Get("Sec-WebSocket-Key"))
|
||||
io.WriteString(brw, "HTTP/1.1 101 Switching Protocols\r\n"+
|
||||
"Upgrade: websocket\r\nConnection: Upgrade\r\n"+
|
||||
"Sec-WebSocket-Accept: "+accept+"\r\n\r\n")
|
||||
if brw.Flush() != nil {
|
||||
conn.Close()
|
||||
return
|
||||
}
|
||||
|
||||
c := &wsConn{conn: conn}
|
||||
h.add(c)
|
||||
if msg := h.errorMessage(); msg != "" {
|
||||
c.sendText(msg) // opened after a failed build => show the overlay right away
|
||||
}
|
||||
// Drain incoming bytes; return (and clean up) when the client goes away.
|
||||
go func() {
|
||||
io.Copy(io.Discard, brw)
|
||||
conn.Close()
|
||||
h.remove(c)
|
||||
}()
|
||||
}
|
||||
|
||||
func acceptKey(key string) string {
|
||||
h := sha1.New()
|
||||
io.WriteString(h, key+"258EAFA5-E914-47DA-95CA-C5AB0DC85B11")
|
||||
return base64.StdEncoding.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
|
||||
type wsConn struct {
|
||||
mu sync.Mutex
|
||||
conn net.Conn
|
||||
}
|
||||
|
||||
// sendText writes a single unmasked text frame (server frames are never masked).
|
||||
func (c *wsConn) sendText(msg string) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
payload := []byte(msg)
|
||||
n := len(payload)
|
||||
var header []byte
|
||||
switch {
|
||||
case n < 126:
|
||||
header = []byte{0x81, byte(n)}
|
||||
case n < 1<<16:
|
||||
header = []byte{0x81, 126, byte(n >> 8), byte(n)}
|
||||
default:
|
||||
header = []byte{0x81, 127,
|
||||
byte(n >> 56), byte(n >> 48), byte(n >> 40), byte(n >> 32),
|
||||
byte(n >> 24), byte(n >> 16), byte(n >> 8), byte(n)}
|
||||
}
|
||||
if _, err := c.conn.Write(header); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := c.conn.Write(payload)
|
||||
return err
|
||||
}
|
||||
149
go/wasmruntime/fetch.go
Normal file
149
go/wasmruntime/fetch.go
Normal file
@@ -0,0 +1,149 @@
|
||||
//go:build js && wasm
|
||||
|
||||
package wasmruntime
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall/js"
|
||||
)
|
||||
|
||||
// absURL resolves a relative path against the current origin (a browser resolves
|
||||
// relative fetch URLs itself, but building the absolute URL keeps behavior
|
||||
// uniform — and lets non-browser hosts, e.g. tests, fetch too).
|
||||
func absURL(url string) string {
|
||||
if strings.HasPrefix(url, "http://") || strings.HasPrefix(url, "https://") {
|
||||
return url
|
||||
}
|
||||
loc := js.Global().Get("location")
|
||||
return loc.Get("protocol").String() + "//" + loc.Get("host").String() + url
|
||||
}
|
||||
|
||||
// Fetch performs an HTTP GET via the browser Fetch API and returns the response
|
||||
// body as a string. It bridges a JS Promise into Go: it registers then/catch
|
||||
// callbacks and blocks the calling goroutine on a channel until the request
|
||||
// settles.
|
||||
//
|
||||
// Because it blocks, call it from a goroutine — never from a reactive callback
|
||||
// or event handler — and write the result into signals when it returns, e.g.:
|
||||
//
|
||||
// go func() {
|
||||
// body, err := Fetch(url)
|
||||
// ...
|
||||
// data.Set(...) // updates the UI reactively
|
||||
// }()
|
||||
func Fetch(url string) (string, error) {
|
||||
var (
|
||||
once sync.Once
|
||||
done = make(chan struct{})
|
||||
body string
|
||||
ferr error
|
||||
onResp, onText, onErr js.Func
|
||||
)
|
||||
finish := func() { once.Do(func() { close(done) }) }
|
||||
|
||||
onErr = js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||
msg := "fetch error"
|
||||
if len(args) > 0 && args[0].Truthy() {
|
||||
e := args[0]
|
||||
if m := e.Get("message"); m.Truthy() {
|
||||
msg = m.String()
|
||||
} else {
|
||||
msg = e.Call("toString").String()
|
||||
}
|
||||
}
|
||||
ferr = errors.New(msg)
|
||||
finish()
|
||||
return nil
|
||||
})
|
||||
|
||||
onText = js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||
if len(args) > 0 {
|
||||
body = args[0].String()
|
||||
}
|
||||
finish()
|
||||
return nil
|
||||
})
|
||||
|
||||
onResp = js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||
resp := args[0]
|
||||
if !resp.Get("ok").Bool() {
|
||||
ferr = fmt.Errorf("HTTP %d", resp.Get("status").Int())
|
||||
finish()
|
||||
return nil
|
||||
}
|
||||
// resp.text() -> Promise<string>
|
||||
resp.Call("text").Call("then", onText).Call("catch", onErr)
|
||||
return nil
|
||||
})
|
||||
|
||||
js.Global().Call("fetch", absURL(url)).Call("then", onResp).Call("catch", onErr)
|
||||
|
||||
<-done
|
||||
onResp.Release()
|
||||
onText.Release()
|
||||
onErr.Release()
|
||||
return body, ferr
|
||||
}
|
||||
|
||||
// FetchBytes is like Fetch but returns the raw response body as bytes (via
|
||||
// resp.arrayBuffer()), for binary payloads such as gob (Fetch's resp.text()
|
||||
// would corrupt non-UTF-8 data). It blocks the calling goroutine the same way —
|
||||
// call it from a goroutine and write the result into signals when it returns.
|
||||
func FetchBytes(url string) ([]byte, error) {
|
||||
var (
|
||||
once sync.Once
|
||||
done = make(chan struct{})
|
||||
body []byte
|
||||
ferr error
|
||||
onResp, onBuf, onErr js.Func
|
||||
)
|
||||
finish := func() { once.Do(func() { close(done) }) }
|
||||
|
||||
onErr = js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||
msg := "fetch error"
|
||||
if len(args) > 0 && args[0].Truthy() {
|
||||
e := args[0]
|
||||
if m := e.Get("message"); m.Truthy() {
|
||||
msg = m.String()
|
||||
} else {
|
||||
msg = e.Call("toString").String()
|
||||
}
|
||||
}
|
||||
ferr = errors.New(msg)
|
||||
finish()
|
||||
return nil
|
||||
})
|
||||
|
||||
onBuf = js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||
if len(args) > 0 {
|
||||
arr := js.Global().Get("Uint8Array").New(args[0])
|
||||
b := make([]byte, arr.Get("length").Int())
|
||||
js.CopyBytesToGo(b, arr)
|
||||
body = b
|
||||
}
|
||||
finish()
|
||||
return nil
|
||||
})
|
||||
|
||||
onResp = js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||
resp := args[0]
|
||||
if !resp.Get("ok").Bool() {
|
||||
ferr = fmt.Errorf("HTTP %d", resp.Get("status").Int())
|
||||
finish()
|
||||
return nil
|
||||
}
|
||||
resp.Call("arrayBuffer").Call("then", onBuf).Call("catch", onErr)
|
||||
return nil
|
||||
})
|
||||
|
||||
js.Global().Call("fetch", absURL(url)).Call("then", onResp).Call("catch", onErr)
|
||||
|
||||
<-done
|
||||
onResp.Release()
|
||||
onBuf.Release()
|
||||
onErr.Release()
|
||||
return body, ferr
|
||||
}
|
||||
86
go/wasmruntime/mount.go
Normal file
86
go/wasmruntime/mount.go
Normal file
@@ -0,0 +1,86 @@
|
||||
//go:build js && wasm
|
||||
|
||||
package wasmruntime
|
||||
|
||||
import (
|
||||
"syscall/js"
|
||||
|
||||
"kjol/vdom"
|
||||
)
|
||||
|
||||
var (
|
||||
rootRender func()
|
||||
renderScheduled bool
|
||||
disposed bool
|
||||
flushFunc js.Func
|
||||
flushInited bool
|
||||
)
|
||||
|
||||
// HasServerContent reports whether #app already holds server-rendered markup
|
||||
// (so the client should Hydrate rather than build from scratch).
|
||||
func HasServerContent() bool {
|
||||
return document.Call("getElementById", "app").Get("firstChild").Truthy()
|
||||
}
|
||||
|
||||
// Run mounts a component fresh (client-side rendering). The component returns a
|
||||
// VNode tree; any signal write re-renders and reconciles into the DOM.
|
||||
func Run(component func() *vdom.VNode) {
|
||||
vdom.Schedule = scheduleRender
|
||||
root := document.Call("getElementById", "app")
|
||||
var prev *vdom.VNode
|
||||
rootRender = func() {
|
||||
next := component()
|
||||
patchChildren(root, one(prev), one(next))
|
||||
prev = next
|
||||
}
|
||||
rootRender()
|
||||
finish(root)
|
||||
}
|
||||
|
||||
// Hydrate attaches to server-rendered DOM: it renders the same initial tree the
|
||||
// server did, adopts the existing nodes (wiring events, no re-creation), then
|
||||
// re-renders reactively from there.
|
||||
func Hydrate(component func() *vdom.VNode) {
|
||||
vdom.Schedule = scheduleRender
|
||||
root := document.Call("getElementById", "app")
|
||||
prev := component()
|
||||
hydrateNode(root.Get("firstChild"), prev)
|
||||
rootRender = func() {
|
||||
next := component()
|
||||
patchChildren(root, one(prev), one(next))
|
||||
prev = next
|
||||
}
|
||||
finish(root)
|
||||
}
|
||||
|
||||
func finish(root js.Value) {
|
||||
// Allow a JS loader to tear this instance down before an in-place hot swap:
|
||||
// snapshot signal state (so the next instance restores it), then clear #app.
|
||||
js.Global().Set("__gowasmDispose", js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||
disposed = true
|
||||
saveState()
|
||||
root.Set("innerHTML", "")
|
||||
return nil
|
||||
}))
|
||||
select {} // keep the runtime alive for event callbacks
|
||||
}
|
||||
|
||||
func scheduleRender() {
|
||||
if disposed || rootRender == nil || renderScheduled {
|
||||
return
|
||||
}
|
||||
renderScheduled = true
|
||||
js.Global().Call("queueMicrotask", ensureFlush())
|
||||
}
|
||||
|
||||
func ensureFlush() js.Func {
|
||||
if !flushInited {
|
||||
flushFunc = js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||
renderScheduled = false
|
||||
rootRender()
|
||||
return nil
|
||||
})
|
||||
flushInited = true
|
||||
}
|
||||
return flushFunc
|
||||
}
|
||||
68
go/wasmruntime/persist_wasm.go
Normal file
68
go/wasmruntime/persist_wasm.go
Normal file
@@ -0,0 +1,68 @@
|
||||
//go:build js && wasm
|
||||
|
||||
package wasmruntime
|
||||
|
||||
import (
|
||||
"syscall/js"
|
||||
|
||||
"kjol/vdom"
|
||||
)
|
||||
|
||||
// State preservation across an in-place hot swap (see the dev server's
|
||||
// livereload script). A fresh wasm instance has fresh Go memory, so signal
|
||||
// values would reset on every rebuild. To keep them, the outgoing instance
|
||||
// snapshots its signals (by creation order, via a vdom.Collector) into
|
||||
// sessionStorage on dispose, and the incoming instance restores them on boot.
|
||||
//
|
||||
// It's best-effort: if an edit adds/removes/reorders signals, the by-order match
|
||||
// shifts and unmatched signals fall back to their initial value — the same
|
||||
// hook-order caveat as server components.
|
||||
|
||||
const stateKey = "__gowasm_hmr_state"
|
||||
|
||||
var stateCollector *vdom.Collector
|
||||
|
||||
// PreserveState registers the collector whose signals are snapshotted to
|
||||
// sessionStorage when this instance is disposed for a hot swap.
|
||||
func PreserveState(c *vdom.Collector) { stateCollector = c }
|
||||
|
||||
// RestoreState returns the signal snapshot saved by a prior instance before a
|
||||
// hot swap, or nil if there is none. It is one-shot: the entry is cleared so a
|
||||
// manual page refresh starts from initial state.
|
||||
func RestoreState() [][]byte {
|
||||
ss := js.Global().Get("sessionStorage")
|
||||
if !ss.Truthy() {
|
||||
return nil
|
||||
}
|
||||
raw := ss.Call("getItem", stateKey)
|
||||
if !raw.Truthy() {
|
||||
return nil
|
||||
}
|
||||
ss.Call("removeItem", stateKey)
|
||||
arr := js.Global().Get("JSON").Call("parse", raw)
|
||||
if arr.Type() != js.TypeObject {
|
||||
return nil
|
||||
}
|
||||
out := make([][]byte, arr.Length())
|
||||
for i := range out {
|
||||
out[i] = []byte(arr.Index(i).String())
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// saveState writes the preserved collector's current signal values to
|
||||
// sessionStorage. Called from the dispose hook before the DOM is torn down.
|
||||
func saveState() {
|
||||
if stateCollector == nil {
|
||||
return
|
||||
}
|
||||
ss := js.Global().Get("sessionStorage")
|
||||
if !ss.Truthy() {
|
||||
return
|
||||
}
|
||||
arr := js.Global().Get("Array").New()
|
||||
for _, b := range stateCollector.Snapshot() {
|
||||
arr.Call("push", string(b))
|
||||
}
|
||||
ss.Call("setItem", stateKey, js.Global().Get("JSON").Call("stringify", arr))
|
||||
}
|
||||
235
go/wasmruntime/reconcile.go
Normal file
235
go/wasmruntime/reconcile.go
Normal file
@@ -0,0 +1,235 @@
|
||||
//go:build js && wasm
|
||||
|
||||
// Package wasmruntime is the wasm client runtime for the neutral vdom package: it
|
||||
// reconciles vdom.VNode trees into the real DOM (fresh mount or hydration of
|
||||
// server-rendered DOM), and drives re-renders when signals change.
|
||||
package wasmruntime
|
||||
|
||||
import (
|
||||
"syscall/js"
|
||||
|
||||
"kjol/vdom"
|
||||
)
|
||||
|
||||
var document = js.Global().Get("document")
|
||||
|
||||
// nodeRT is the per-node bookkeeping stored in VNode.Runtime (wasm only).
|
||||
type nodeRT struct {
|
||||
dom js.Value
|
||||
jsFuncs map[string]js.Func
|
||||
refs map[string]*handlerRef
|
||||
}
|
||||
|
||||
type handlerRef struct{ fn func(vdom.Event) }
|
||||
|
||||
func rt(n *vdom.VNode) *nodeRT {
|
||||
if n.Runtime == nil {
|
||||
n.Runtime = &nodeRT{jsFuncs: map[string]js.Func{}, refs: map[string]*handlerRef{}}
|
||||
}
|
||||
return n.Runtime.(*nodeRT)
|
||||
}
|
||||
|
||||
// clientEvent adapts a DOM event to vdom.Event.
|
||||
type clientEvent struct{ js js.Value }
|
||||
|
||||
func (e clientEvent) PreventDefault() { e.js.Call("preventDefault") }
|
||||
|
||||
func (e clientEvent) Value() string {
|
||||
t := e.js.Get("target")
|
||||
if !t.Truthy() {
|
||||
return ""
|
||||
}
|
||||
v := t.Get("value")
|
||||
if !v.Truthy() {
|
||||
return "" // e.g. a button has no value
|
||||
}
|
||||
return v.String()
|
||||
}
|
||||
|
||||
func one(n *vdom.VNode) []*vdom.VNode {
|
||||
if n == nil {
|
||||
return nil
|
||||
}
|
||||
return []*vdom.VNode{n}
|
||||
}
|
||||
|
||||
// ---- fresh create + diff ----
|
||||
|
||||
func createDOM(n *vdom.VNode) js.Value {
|
||||
if n.Tag == "" {
|
||||
d := document.Call("createTextNode", n.Text)
|
||||
rt(n).dom = d
|
||||
return d
|
||||
}
|
||||
el := document.Call("createElement", n.Tag)
|
||||
rt(n).dom = el
|
||||
for k, v := range n.Attrs {
|
||||
el.Call("setAttribute", k, v)
|
||||
}
|
||||
for k, v := range n.Props {
|
||||
el.Set(k, v)
|
||||
}
|
||||
for name, h := range n.Events {
|
||||
addListener(n, name, h)
|
||||
}
|
||||
if n.HTML != "" {
|
||||
el.Set("innerHTML", n.HTML)
|
||||
return el
|
||||
}
|
||||
for _, c := range n.Children {
|
||||
el.Call("appendChild", createDOM(c))
|
||||
}
|
||||
return el
|
||||
}
|
||||
|
||||
func patchChildren(parent js.Value, old, next []*vdom.VNode) {
|
||||
n := max(len(old), len(next))
|
||||
for i := range n {
|
||||
var o, x *vdom.VNode
|
||||
if i < len(old) {
|
||||
o = old[i]
|
||||
}
|
||||
if i < len(next) {
|
||||
x = next[i]
|
||||
}
|
||||
patch(parent, o, x)
|
||||
}
|
||||
}
|
||||
|
||||
func patch(parent js.Value, o, x *vdom.VNode) {
|
||||
switch {
|
||||
case o == nil && x == nil:
|
||||
return
|
||||
case o == nil:
|
||||
parent.Call("appendChild", createDOM(x))
|
||||
case x == nil:
|
||||
parent.Call("removeChild", rt(o).dom)
|
||||
release(o)
|
||||
case o.Tag != x.Tag:
|
||||
parent.Call("replaceChild", createDOM(x), rt(o).dom)
|
||||
release(o)
|
||||
default:
|
||||
x.Runtime = o.Runtime // adopt dom + listeners
|
||||
dom := rt(x).dom
|
||||
if !dom.Truthy() {
|
||||
// o was a hydration hole (no adopted DOM node, e.g. a server/client
|
||||
// markup mismatch). Recreate this node fresh instead of calling into an
|
||||
// undefined DOM handle.
|
||||
parent.Call("appendChild", createDOM(x))
|
||||
return
|
||||
}
|
||||
if x.Tag == "" {
|
||||
if x.Text != o.Text {
|
||||
dom.Set("nodeValue", x.Text)
|
||||
}
|
||||
return
|
||||
}
|
||||
updateAttrs(o, x)
|
||||
updateProps(o, x)
|
||||
updateEvents(o, x)
|
||||
if x.HTML != "" {
|
||||
if x.HTML != o.HTML {
|
||||
dom.Set("innerHTML", x.HTML)
|
||||
}
|
||||
return
|
||||
}
|
||||
patchChildren(dom, o.Children, x.Children)
|
||||
}
|
||||
}
|
||||
|
||||
func updateAttrs(o, x *vdom.VNode) {
|
||||
dom := rt(x).dom
|
||||
for k := range o.Attrs {
|
||||
if _, ok := x.Attrs[k]; !ok {
|
||||
dom.Call("removeAttribute", k)
|
||||
}
|
||||
}
|
||||
for k, v := range x.Attrs {
|
||||
if o.Attrs[k] != v {
|
||||
dom.Call("setAttribute", k, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func updateProps(o, x *vdom.VNode) {
|
||||
dom := rt(x).dom
|
||||
for k, v := range x.Props {
|
||||
if o.Props[k] != v && dom.Get(k).String() != v {
|
||||
dom.Set(k, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func updateEvents(o, x *vdom.VNode) {
|
||||
r := rt(x) // same nodeRT as o (adopted above)
|
||||
for name, fn := range r.jsFuncs {
|
||||
if _, ok := x.Events[name]; !ok {
|
||||
r.dom.Call("removeEventListener", name, fn)
|
||||
fn.Release()
|
||||
delete(r.jsFuncs, name)
|
||||
delete(r.refs, name)
|
||||
}
|
||||
}
|
||||
for name, h := range x.Events {
|
||||
if ref, ok := r.refs[name]; ok {
|
||||
ref.fn = h
|
||||
} else {
|
||||
addListener(x, name, h)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func addListener(n *vdom.VNode, name string, handler func(vdom.Event)) {
|
||||
r := rt(n)
|
||||
ref := &handlerRef{fn: handler}
|
||||
fn := js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||
var ev js.Value
|
||||
if len(args) > 0 {
|
||||
ev = args[0]
|
||||
}
|
||||
ref.fn(clientEvent{js: ev})
|
||||
return nil
|
||||
})
|
||||
r.dom.Call("addEventListener", name, fn)
|
||||
r.jsFuncs[name] = fn
|
||||
r.refs[name] = ref
|
||||
}
|
||||
|
||||
func release(n *vdom.VNode) {
|
||||
if n.Runtime != nil {
|
||||
for _, fn := range rt(n).jsFuncs {
|
||||
fn.Release()
|
||||
}
|
||||
}
|
||||
for _, c := range n.Children {
|
||||
release(c)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- hydration: adopt server-rendered DOM instead of creating it ----
|
||||
|
||||
func hydrateNode(dom js.Value, n *vdom.VNode) {
|
||||
if !dom.Truthy() {
|
||||
return // structural mismatch; leave a hole (a later re-render will fix)
|
||||
}
|
||||
rt(n).dom = dom
|
||||
if n.Tag == "" {
|
||||
if dom.Get("nodeValue").String() != n.Text {
|
||||
dom.Set("nodeValue", n.Text)
|
||||
}
|
||||
return
|
||||
}
|
||||
for name, h := range n.Events {
|
||||
addListener(n, name, h)
|
||||
}
|
||||
for k, v := range n.Props {
|
||||
dom.Set(k, v)
|
||||
}
|
||||
if n.HTML != "" {
|
||||
return // trust server-rendered HTML
|
||||
}
|
||||
childNodes := dom.Get("childNodes")
|
||||
for i, c := range n.Children {
|
||||
hydrateNode(childNodes.Index(i), c)
|
||||
}
|
||||
}
|
||||
38
go/wasmruntime/router.go
Normal file
38
go/wasmruntime/router.go
Normal file
@@ -0,0 +1,38 @@
|
||||
//go:build js && wasm
|
||||
|
||||
package wasmruntime
|
||||
|
||||
import (
|
||||
"syscall/js"
|
||||
|
||||
"kjol/vdom"
|
||||
)
|
||||
|
||||
// Router holds the current path in a signal, so reading Path() during render
|
||||
// re-renders on navigation (and browser back/forward).
|
||||
type Router struct {
|
||||
path *vdom.Signal[string]
|
||||
}
|
||||
|
||||
func NewRouter() *Router {
|
||||
r := &Router{path: vdom.NewSignal(currentPath())}
|
||||
popstate := js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||
r.path.Set(currentPath())
|
||||
return nil
|
||||
})
|
||||
js.Global().Call("addEventListener", "popstate", popstate)
|
||||
return r
|
||||
}
|
||||
|
||||
func currentPath() string {
|
||||
return js.Global().Get("location").Get("pathname").String()
|
||||
}
|
||||
|
||||
// Path returns the current route (reactive when read during render).
|
||||
func (r *Router) Path() string { return r.path.Get() }
|
||||
|
||||
// Navigate pushes a history entry and re-renders (client-side SPA navigation).
|
||||
func (r *Router) Navigate(path string) {
|
||||
js.Global().Get("history").Call("pushState", nil, "", path)
|
||||
r.path.Set(path)
|
||||
}
|
||||
7
go/wasmruntime/wasmruntime.go
Normal file
7
go/wasmruntime/wasmruntime.go
Normal file
@@ -0,0 +1,7 @@
|
||||
//go:build !(js && wasm)
|
||||
|
||||
// Package wasmruntime is the browser-side client runtime (reconciler, mount /
|
||||
// hydrate, router, fetch, hot-reload state preservation). Its implementation
|
||||
// compiles only under GOOS=js GOARCH=wasm; this placeholder keeps the package
|
||||
// non-empty on other platforms so `go build ./...` / `go vet ./...` succeed.
|
||||
package wasmruntime
|
||||
57
go/webui/README.md
Normal file
57
go/webui/README.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# webui — Go/WebAssembly component kit
|
||||
|
||||
A Go port of kjol's Solid.js component kit (`web/kit`) for the **gowasm** engine.
|
||||
Components are neutral `*vdom.VNode` builders (SSR on the server, hydrate on the
|
||||
client) styled with **Tailwind** utility classes. Import as `kjol/webui`.
|
||||
|
||||
```go
|
||||
import ui "kjol/webui"
|
||||
|
||||
ui.Button(ui.ButtonProps{Color: ui.ButtonPrimary, Text: "Save", OnClick: save})
|
||||
ui.Alert(ui.AlertGreen, "Done", vdom.Text("Saved."))
|
||||
ui.ToggleSwitch(on, func(v bool){ on = v }, "Notifications", "", false, "")
|
||||
```
|
||||
|
||||
See the runnable **`/kit`** demo page in `cmd/examples/go-wasm-web` (app nav → "UI Kit").
|
||||
|
||||
## Conventions
|
||||
|
||||
- A TSX `function Foo(props)` → `func Foo(p FooProps, children ...*vdom.VNode) *vdom.VNode`
|
||||
(simple components take positional params). Reactive accessors collapse: pass plain
|
||||
current values (read a signal at the call site); state changes come back via `OnChange`
|
||||
callbacks (the whole tree re-renders and reconciles).
|
||||
- Tailwind classes are copied verbatim so `kjol/cmd/twcss` (scanning these `.go` files)
|
||||
emits the CSS. Custom tokens (`rounded-default`, `bg-primary`, `text-text-heading`, …)
|
||||
come from the app's `@theme` block.
|
||||
- Icons via `Icon(name, size, class)`; the registry ships a small default set, apps add
|
||||
more with `RegisterIcon`. Unregistered names render an empty (correctly-sized) box.
|
||||
|
||||
## Components
|
||||
|
||||
- **Layout/content:** `Card*`, `BorderCard`, `CardHeader`, `PageHeader`, `PageContainer`,
|
||||
`Breadcrumbs`, `Divider`, `CodeBox`, `Loader`, `Sidebar*`, `Icon*`.
|
||||
- **Controls:** `Button`, `ButtonLink`, `SegmentedButtons`, `BackLink`, `Badge`,
|
||||
`ToggleSwitch`, and the `Form*` family (`FormInput`, `FormTextarea`, `FormSelect`,
|
||||
`FormLabel`, `FormFieldset`, `FormNumberInput`/`FormCurrencyInput`/`FormEmailInput`/… ).
|
||||
- **Disclosure/nav:** `Alert`, `TabGroup`, `CrmTabGroup`, `Accordion`/`SingleAccordion`,
|
||||
`Menu`*, `Submenu`, `EnvBadge`, `RemoteUpdateFlash`.
|
||||
- **Overlays/floating:** `Modal`/`ConfirmModal`/`WizardModal`, `Toast*`, `Tooltip`/
|
||||
`HoverTooltip`, `Popover`/`HoverPopover`, `Floating*`.
|
||||
- **Data:** `PrettyTable`, `AutoTable`, `CellGrid`, `ReactiveChart`, `Calendar`,
|
||||
`DatePicker`, `FuzzyMatch*` (+ pure matchers), `Tutorial`.
|
||||
- **Pure logic (no vdom):** `formatters.go` (`FormatPhoneNumber`, `FormatDate`, …) and
|
||||
`validation.go` (`IsEmailValid`, `CreateValidation`, …).
|
||||
|
||||
## Runtime limitations (what the TSX did that this can't)
|
||||
|
||||
The neutral runtime has **no floating-ui, portals, refs, element measurement, focus
|
||||
traps, or timers**. Components that relied on those are ported as **structure +
|
||||
Tailwind + signal/event wiring**, with open/selection state lifted to props +
|
||||
callbacks and positioning approximated with static Tailwind. Each such gap is marked
|
||||
with a `// NOTE:` in the component's file. Concretely: dropdown/menu/tooltip/modal
|
||||
positioning is static (not computed); outside-click / Escape / hover-delay dismissal,
|
||||
auto-dismiss timers, and enter/exit animations are dropped (caller-driven); `AutoTable`
|
||||
omits virtual scrolling, column resize/reorder, inline editing, and the formula engine;
|
||||
`ReactiveChart` renders only its container (the example draws charts server-side with
|
||||
go-chart); a few `Form*` controls that needed canvas/async (`FormSignaturePad`,
|
||||
`FormAsyncCombobox`) are omitted. Everything compiles on native (SSR) and js/wasm.
|
||||
131
go/webui/accordion.go
Normal file
131
go/webui/accordion.go
Normal file
@@ -0,0 +1,131 @@
|
||||
package webui
|
||||
|
||||
import "kjol/vdom"
|
||||
|
||||
// Port of web/kit/Accordion.tsx.
|
||||
//
|
||||
// NOTE: The TSX AccordionItem kept its open state in an internal signal (seeded
|
||||
// by startOpen, synced from an optional isOpen prop via createEffect). Per the
|
||||
// porting guide, selection state collapses to a plain value + callback: IsOpen
|
||||
// is a plain bool and OnToggle fires on click (the caller flips its own state).
|
||||
// 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 accordionTitleCls = "ui-accordion-title flex-1"
|
||||
const accordionContentCls = "ui-accordion-content px-4 pb-4 text-neutral-700"
|
||||
|
||||
func accordionIconCls(open bool) string {
|
||||
c := "ui-accordion-icon text-neutral-500 leading-none transition-transform duration-200"
|
||||
if open {
|
||||
c += " rotate-180"
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// AccordionItemProps configures a single AccordionItem. IsOpen is the current
|
||||
// open state; OnToggle fires when the (enabled) trigger is clicked.
|
||||
type AccordionItemProps struct {
|
||||
IsOpen bool
|
||||
Disabled bool
|
||||
Title string
|
||||
OnToggle func()
|
||||
}
|
||||
|
||||
// AccordionItem is one collapsible row: a trigger button plus its content
|
||||
// (children), shown when open and not disabled.
|
||||
func AccordionItem(p AccordionItemProps, children ...*vdom.VNode) *vdom.VNode {
|
||||
ariaExpanded := "false"
|
||||
if p.IsOpen {
|
||||
ariaExpanded = "true"
|
||||
}
|
||||
trigger := []vdom.Mod{
|
||||
vdom.Attr("type", "button"),
|
||||
vdom.Attr("class", accordionTriggerCls),
|
||||
vdom.Attr("aria-expanded", ariaExpanded),
|
||||
vdom.On(vdom.EVENT_CLICK, func() {
|
||||
if p.Disabled {
|
||||
return
|
||||
}
|
||||
if p.OnToggle != nil {
|
||||
p.OnToggle()
|
||||
}
|
||||
}),
|
||||
vdom.El("span", vdom.Attr("class", accordionTitleCls), vdom.Text(p.Title)),
|
||||
}
|
||||
if p.Disabled {
|
||||
trigger = append(trigger, vdom.Attr("disabled", "disabled"))
|
||||
} else {
|
||||
chev := "chevron-down"
|
||||
if p.IsOpen {
|
||||
chev = "chevron-up"
|
||||
}
|
||||
trigger = append(trigger, vdom.El("span",
|
||||
vdom.Attr("class", accordionIconCls(p.IsOpen)),
|
||||
Icon(chev, 18, ""),
|
||||
))
|
||||
}
|
||||
|
||||
mods := []vdom.Mod{vdom.Attr("class", accordionItemCls), vdom.El("button", trigger...)}
|
||||
if p.IsOpen && !p.Disabled {
|
||||
mods = append(mods, vdom.El("div", kids([]vdom.Mod{vdom.Attr("class", accordionContentCls)}, children)...))
|
||||
}
|
||||
return vdom.El("div", mods...)
|
||||
}
|
||||
|
||||
// AccordionItemData is one entry for Accordion / SingleAccordion.
|
||||
type AccordionItemData struct {
|
||||
Title string
|
||||
Content *vdom.VNode
|
||||
Disabled bool
|
||||
}
|
||||
|
||||
// Accordion renders independently-collapsible items. open[i] is item i's open
|
||||
// state (missing entries are closed); clicking item i calls onToggle(i) — the
|
||||
// caller flips open[i].
|
||||
func Accordion(items []AccordionItemData, open []bool, onToggle func(int)) *vdom.VNode {
|
||||
mods := []vdom.Mod{vdom.Attr("class", accordionRootCls)}
|
||||
for i, item := range items {
|
||||
idx := i
|
||||
isOpen := i < len(open) && open[i]
|
||||
mods = append(mods, AccordionItem(AccordionItemProps{
|
||||
IsOpen: isOpen,
|
||||
Disabled: item.Disabled,
|
||||
Title: item.Title,
|
||||
OnToggle: func() {
|
||||
if onToggle != nil {
|
||||
onToggle(idx)
|
||||
}
|
||||
},
|
||||
}, item.Content))
|
||||
}
|
||||
return vdom.El("div", mods...)
|
||||
}
|
||||
|
||||
// SingleAccordion renders items where at most one is open. openIndex is the open
|
||||
// item (-1 = none); clicking an item calls onChange with the new open index —
|
||||
// the clicked index, or -1 if that item was already open.
|
||||
func SingleAccordion(items []AccordionItemData, openIndex int, onChange func(int)) *vdom.VNode {
|
||||
mods := []vdom.Mod{vdom.Attr("class", accordionRootCls)}
|
||||
for i, item := range items {
|
||||
idx := i
|
||||
mods = append(mods, AccordionItem(AccordionItemProps{
|
||||
IsOpen: openIndex == i,
|
||||
Disabled: item.Disabled,
|
||||
Title: item.Title,
|
||||
OnToggle: func() {
|
||||
if onChange == nil {
|
||||
return
|
||||
}
|
||||
if openIndex == idx {
|
||||
onChange(-1)
|
||||
} else {
|
||||
onChange(idx)
|
||||
}
|
||||
},
|
||||
}, item.Content))
|
||||
}
|
||||
return vdom.El("div", mods...)
|
||||
}
|
||||
41
go/webui/alerts.go
Normal file
41
go/webui/alerts.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package webui
|
||||
|
||||
import "kjol/vdom"
|
||||
|
||||
// Port of web/kit/Alerts.tsx. The TSX exports one component per color
|
||||
// (AlertWhite, AlertBlue, …); here that's Alert(color, header, children).
|
||||
|
||||
const (
|
||||
AlertWhite = "white"
|
||||
AlertGray = "gray"
|
||||
AlertBlue = "blue"
|
||||
AlertGreen = "green"
|
||||
AlertRed = "red"
|
||||
AlertYellow = "yellow"
|
||||
)
|
||||
|
||||
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",
|
||||
}
|
||||
|
||||
// Alert renders a colored callout. header is optional (rendered as a bold title
|
||||
// above the body); children form the body.
|
||||
func Alert(color, header string, children ...*vdom.VNode) *vdom.VNode {
|
||||
cc := alertColors[color]
|
||||
if cc == "" {
|
||||
cc = alertColors["white"]
|
||||
}
|
||||
mods := []vdom.Mod{vdom.Attr("class", cx(alertBase, cc))}
|
||||
if header != "" {
|
||||
mods = append(mods, vdom.El("h3", vdom.Attr("class", "font-semibold mb-2"), vdom.Text(header)))
|
||||
}
|
||||
mods = append(mods, vdom.El("p", kids([]vdom.Mod{vdom.Attr("class", "text-sm")}, children)...))
|
||||
return vdom.El("div", mods...)
|
||||
}
|
||||
601
go/webui/autotable.go
Normal file
601
go/webui/autotable.go
Normal file
@@ -0,0 +1,601 @@
|
||||
// Port of web/kit/AutoTable.tsx.
|
||||
//
|
||||
// NOTE: AutoTable.tsx is ~4200 LOC and heavily coupled to the browser. This is a
|
||||
// faithful, COMPILING *core* — the column model, the header/body/pagination
|
||||
// render shell, and the Tailwind styling — not a behavioral clone. The following
|
||||
// substantial features of the TSX are INTENTIONALLY OUT OF SCOPE here because
|
||||
// they depend on DOM measurement, refs, timers, portals, or floating-ui, none of
|
||||
// which exist in the neutral vdom runtime:
|
||||
//
|
||||
// - Virtual scrolling / windowed row rendering (rows are rendered eagerly).
|
||||
// - Column drag-to-reorder and drag-to-resize (getBoundingClientRect, refs,
|
||||
// mousemove tracking) — replaced by a static per-column WidthClass.
|
||||
// - Computed/pinned table sizing (pinnedTableWidth measurement) — replaced by
|
||||
// table-fixed + static WidthClass.
|
||||
// - Runtime-editable calculated columns and footer summary rows, the whole
|
||||
// Excel-style formula engine (tokenize/compile/highlight), and their popover
|
||||
// editors (Popover/Menu/floating-ui).
|
||||
// - CSV and PDF export (pdf-lib / pdfjs-dist), and the export/customize popover.
|
||||
// - Remote data fetching (authFetch), remoteFiltering, refreshSignal.
|
||||
// - Search/filter toolbar, quick-date presets, column-visibility toggles, the
|
||||
// search-aside card, and localStorage persistence of order/width/columns.
|
||||
// - Accordion expand/collapse rows and row-highlight auto-paging.
|
||||
// - Client-side sorting/filtering/pagination logic (compareRowsGeneric,
|
||||
// processDataLocally): sort state and pagination are surfaced here as plain
|
||||
// value props + callbacks; the caller owns the actual sort/page computation.
|
||||
//
|
||||
// What IS ported: the AutoTableColumn model (header text, alignment, width class,
|
||||
// sortable + a cell render func), the AutoTable render shell (sticky header,
|
||||
// body rows, loading skeletons, error/empty states, display-only pagination),
|
||||
// the verbatim Tailwind class constants/maps, and sort/pagination as props +
|
||||
// callbacks. Reactive accessors collapse to plain values per the port guide.
|
||||
package webui
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"kjol/vdom"
|
||||
)
|
||||
|
||||
// ColumnPosition is a cell/header alignment (mirrors the TSX 0|1|2 union).
|
||||
type ColumnPosition int
|
||||
|
||||
const (
|
||||
COL_POS_LEFT ColumnPosition = 0
|
||||
COL_POS_RIGHT ColumnPosition = 1
|
||||
COL_POS_CENTER ColumnPosition = 2
|
||||
)
|
||||
|
||||
// AutoTableHeaderColor selects the header/body color scheme.
|
||||
type AutoTableHeaderColor int
|
||||
|
||||
const (
|
||||
AUTOTABLE_HEADER_COLOR_DEFAULT AutoTableHeaderColor = 0
|
||||
AUTOTABLE_HEADER_COLOR_BLUE AutoTableHeaderColor = 1
|
||||
AUTOTABLE_HEADER_COLOR_GREEN AutoTableHeaderColor = 2
|
||||
AUTOTABLE_HEADER_COLOR_GRAY AutoTableHeaderColor = 3
|
||||
AUTOTABLE_HEADER_COLOR_DARK_BLUE AutoTableHeaderColor = 4
|
||||
)
|
||||
|
||||
// AutoTableSize selects header/body/pagination density.
|
||||
type AutoTableSize int
|
||||
|
||||
const (
|
||||
AUTOTABLE_SIZE_DEFAULT AutoTableSize = 0
|
||||
AUTOTABLE_SIZE_COMPACT AutoTableSize = 1
|
||||
AUTOTABLE_SIZE_SUPERCOMPACT AutoTableSize = 2
|
||||
)
|
||||
|
||||
// -- Tailwind class maps (copied verbatim from AutoTable.tsx) ---------------
|
||||
|
||||
// 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_BLUE: "bg-sky-700 text-white",
|
||||
AUTOTABLE_HEADER_COLOR_GREEN: "bg-green-700 text-white",
|
||||
AUTOTABLE_HEADER_COLOR_GRAY: "bg-neutral-600 text-white",
|
||||
AUTOTABLE_HEADER_COLOR_DARK_BLUE: "bg-sky-900 text-white",
|
||||
}
|
||||
|
||||
// atHeaderSortHoverCls is the sortable-hover override per color.
|
||||
var atHeaderSortHoverCls = map[AutoTableHeaderColor]string{
|
||||
AUTOTABLE_HEADER_COLOR_DEFAULT: "hover:bg-neutral-300",
|
||||
AUTOTABLE_HEADER_COLOR_BLUE: "hover:bg-sky-500",
|
||||
AUTOTABLE_HEADER_COLOR_GREEN: "hover:bg-green-800",
|
||||
AUTOTABLE_HEADER_COLOR_GRAY: "hover:bg-neutral-500",
|
||||
AUTOTABLE_HEADER_COLOR_DARK_BLUE: "hover:bg-sky-800",
|
||||
}
|
||||
|
||||
// HEADER_TEXT_CLS is the header text weight/case per color.
|
||||
var HEADER_TEXT_CLS = map[AutoTableHeaderColor]string{
|
||||
AUTOTABLE_HEADER_COLOR_DEFAULT: "font-bold uppercase tracking-wider",
|
||||
AUTOTABLE_HEADER_COLOR_BLUE: "font-semibold",
|
||||
AUTOTABLE_HEADER_COLOR_GREEN: "font-semibold",
|
||||
AUTOTABLE_HEADER_COLOR_GRAY: "font-semibold",
|
||||
AUTOTABLE_HEADER_COLOR_DARK_BLUE: "font-semibold",
|
||||
}
|
||||
|
||||
// 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_BLUE: "text-white",
|
||||
AUTOTABLE_HEADER_COLOR_GREEN: "text-white",
|
||||
AUTOTABLE_HEADER_COLOR_GRAY: "text-white",
|
||||
AUTOTABLE_HEADER_COLOR_DARK_BLUE: "text-white",
|
||||
}
|
||||
|
||||
// HEADER_PADDING_CLS is the th padding per table size.
|
||||
var HEADER_PADDING_CLS = map[AutoTableSize]string{
|
||||
AUTOTABLE_SIZE_DEFAULT: "p-4 text-sm",
|
||||
AUTOTABLE_SIZE_COMPACT: "py-1.5 px-2 text-sm",
|
||||
AUTOTABLE_SIZE_SUPERCOMPACT: "py-1 px-2 text-xs",
|
||||
}
|
||||
|
||||
// BODY_PADDING_CLS is the body cell padding per table size (applied via [&_td]:).
|
||||
var BODY_PADDING_CLS = map[AutoTableSize]string{
|
||||
AUTOTABLE_SIZE_DEFAULT: "text-sm [&_td]:p-4",
|
||||
AUTOTABLE_SIZE_COMPACT: "text-sm [&_td]:py-1 [&_td]:px-2",
|
||||
AUTOTABLE_SIZE_SUPERCOMPACT: "text-xs [&_td]:py-0.5 [&_td]:px-2",
|
||||
}
|
||||
|
||||
// atPaginationPaddingCls is the pagination bar padding per table size.
|
||||
var atPaginationPaddingCls = map[AutoTableSize]string{
|
||||
AUTOTABLE_SIZE_DEFAULT: "py-3 px-4",
|
||||
AUTOTABLE_SIZE_COMPACT: "py-1 px-4",
|
||||
AUTOTABLE_SIZE_SUPERCOMPACT: "py-1 px-4",
|
||||
}
|
||||
|
||||
// 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",
|
||||
}
|
||||
|
||||
// POS_CLS is the text alignment per ColumnPosition.
|
||||
var POS_CLS = map[ColumnPosition]string{
|
||||
COL_POS_LEFT: "text-left",
|
||||
COL_POS_RIGHT: "text-right",
|
||||
COL_POS_CENTER: "text-center",
|
||||
}
|
||||
|
||||
// HEADER_INNER_POS is the header inner flex direction per position.
|
||||
var HEADER_INNER_POS = map[ColumnPosition]string{
|
||||
COL_POS_LEFT: "",
|
||||
COL_POS_RIGHT: "flex-row-reverse",
|
||||
COL_POS_CENTER: "justify-center",
|
||||
}
|
||||
|
||||
// -- 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_WRAPPER = "overflow-x-auto w-full"
|
||||
TBL_BASE = "min-w-full"
|
||||
|
||||
HEADER_CONTENT = "transition-transform duration-150 ease-in-out"
|
||||
HEADER_INNER_BASE = "flex justify-between gap-2 items-center"
|
||||
|
||||
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"
|
||||
|
||||
atPaginationBase = "flex justify-between items-center border-t border-neutral-300"
|
||||
atPaginationInfo = "hidden sm:flex items-center text-sm text-neutral-500"
|
||||
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"
|
||||
|
||||
// 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"
|
||||
)
|
||||
|
||||
// AutoTableColumn describes one column: its header text, alignment, an optional
|
||||
// static Tailwind width class (the static replacement for the TSX's measured/
|
||||
// resizable widths), whether it is sortable (and under which identifier), and a
|
||||
// cell render func returning the full <td> for a given row. If Cell is nil an
|
||||
// empty aligned <td> is rendered.
|
||||
//
|
||||
// NOTE: The TSX AutoTableColumn also carries csv/csvValue, sortType/sortValue,
|
||||
// toggleable/hiddenByDefault, and a `calculated` spec — all tied to features that
|
||||
// are out of scope here (export, local sort, toggles, calculated columns).
|
||||
type AutoTableColumn struct {
|
||||
DisplayName string
|
||||
DisplayPosition ColumnPosition
|
||||
WidthClass string // static Tailwind width, e.g. "w-32" (replaces measured sizing)
|
||||
HeaderClasses string
|
||||
Sortable bool
|
||||
SortIdentifier string
|
||||
Cell func(row any) *vdom.VNode
|
||||
}
|
||||
|
||||
// AutoTableOrderBy is the active sort (mirrors the TSX interface).
|
||||
type AutoTableOrderBy struct {
|
||||
Identifier string
|
||||
Descending bool
|
||||
}
|
||||
|
||||
// AutoTablePagination is the display-only pagination state (mirrors the TSX
|
||||
// interface; only the display fields are used by this core).
|
||||
type AutoTablePagination struct {
|
||||
CurrentPage int
|
||||
TotalPages int
|
||||
TotalItems int
|
||||
MaxItemsPerPage int
|
||||
ViewRangeLower int
|
||||
ViewRangeUpper int
|
||||
}
|
||||
|
||||
// atConfig holds resolved AutoTable options. Defaults mirror the TSX opts memo.
|
||||
type atConfig struct {
|
||||
size AutoTableSize
|
||||
color AutoTableHeaderColor
|
||||
shadow bool
|
||||
hover bool
|
||||
alternate bool
|
||||
headerBorderY bool
|
||||
surroundingBorder bool
|
||||
borderX bool
|
||||
borderY bool
|
||||
tableLayoutAuto bool
|
||||
hidePagination bool
|
||||
|
||||
loading bool
|
||||
errorMsg string
|
||||
emptyMessage string
|
||||
|
||||
sortIdentifier string
|
||||
sortDescending bool
|
||||
onSort func(identifier string)
|
||||
|
||||
pagination *AutoTablePagination
|
||||
onPageChange func(page int)
|
||||
onItemsPerPage func(n int)
|
||||
|
||||
class string
|
||||
}
|
||||
|
||||
// AutoTableOption configures AutoTable (functional-options for the variadic opts).
|
||||
type AutoTableOption func(*atConfig)
|
||||
|
||||
// AutoTableWithSize sets the density (default / compact / supercompact).
|
||||
func AutoTableWithSize(s AutoTableSize) AutoTableOption {
|
||||
return func(c *atConfig) { c.size = s }
|
||||
}
|
||||
|
||||
// AutoTableWithColor sets the header/body color scheme.
|
||||
func AutoTableWithColor(color AutoTableHeaderColor) AutoTableOption {
|
||||
return func(c *atConfig) { c.color = color }
|
||||
}
|
||||
|
||||
// AutoTableWithHover enables per-row hover highlighting.
|
||||
func AutoTableWithHover() AutoTableOption { return func(c *atConfig) { c.hover = true } }
|
||||
|
||||
// AutoTableWithAlternate enables zebra striping on odd rows.
|
||||
func AutoTableWithAlternate() AutoTableOption { return func(c *atConfig) { c.alternate = true } }
|
||||
|
||||
// AutoTableWithShadow adds a drop shadow to the table container.
|
||||
func AutoTableWithShadow() AutoTableOption { return func(c *atConfig) { c.shadow = true } }
|
||||
|
||||
// AutoTableWithSurroundingBorder draws a border around the table container.
|
||||
func AutoTableWithSurroundingBorder() AutoTableOption {
|
||||
return func(c *atConfig) { c.surroundingBorder = true }
|
||||
}
|
||||
|
||||
// AutoTableWithHeaderBorderY adds vertical dividers between header cells.
|
||||
func AutoTableWithHeaderBorderY() AutoTableOption {
|
||||
return func(c *atConfig) { c.headerBorderY = true }
|
||||
}
|
||||
|
||||
// AutoTableWithBorderX draws horizontal dividers between body rows.
|
||||
func AutoTableWithBorderX() AutoTableOption { return func(c *atConfig) { c.borderX = true } }
|
||||
|
||||
// AutoTableWithBorderY draws vertical dividers between body cells.
|
||||
func AutoTableWithBorderY() AutoTableOption { return func(c *atConfig) { c.borderY = true } }
|
||||
|
||||
// AutoTableWithTableLayoutAuto uses auto table layout instead of table-fixed.
|
||||
func AutoTableWithTableLayoutAuto() AutoTableOption {
|
||||
return func(c *atConfig) { c.tableLayoutAuto = true }
|
||||
}
|
||||
|
||||
// AutoTableWithLoading renders skeleton placeholder rows instead of data.
|
||||
func AutoTableWithLoading(loading bool) AutoTableOption {
|
||||
return func(c *atConfig) { c.loading = loading }
|
||||
}
|
||||
|
||||
// AutoTableWithError renders a single error row with the given message.
|
||||
func AutoTableWithError(msg string) AutoTableOption {
|
||||
return func(c *atConfig) { c.errorMsg = msg }
|
||||
}
|
||||
|
||||
// AutoTableWithEmptyMessage overrides the "No entries found." empty-state text.
|
||||
func AutoTableWithEmptyMessage(msg string) AutoTableOption {
|
||||
return func(c *atConfig) { c.emptyMessage = msg }
|
||||
}
|
||||
|
||||
// AutoTableWithSort surfaces the active sort as a plain value + callback. onSort
|
||||
// is invoked with a sortable column's SortIdentifier when its header is clicked;
|
||||
// the caller owns the actual re-sorting (the TSX's local sort is out of scope).
|
||||
func AutoTableWithSort(identifier string, descending bool, onSort func(identifier string)) AutoTableOption {
|
||||
return func(c *atConfig) {
|
||||
c.sortIdentifier = identifier
|
||||
c.sortDescending = descending
|
||||
c.onSort = onSort
|
||||
}
|
||||
}
|
||||
|
||||
// AutoTableWithPagination surfaces display-only pagination state + callbacks. The
|
||||
// caller owns the actual paging/query computation.
|
||||
func AutoTableWithPagination(p *AutoTablePagination, onPageChange func(page int), onItemsPerPage func(n int)) AutoTableOption {
|
||||
return func(c *atConfig) {
|
||||
c.pagination = p
|
||||
c.onPageChange = onPageChange
|
||||
c.onItemsPerPage = onItemsPerPage
|
||||
}
|
||||
}
|
||||
|
||||
// AutoTableWithHidePagination hides the pagination bar.
|
||||
func AutoTableWithHidePagination() AutoTableOption {
|
||||
return func(c *atConfig) { c.hidePagination = true }
|
||||
}
|
||||
|
||||
// AutoTableWithClass appends classes to the outermost wrapper.
|
||||
func AutoTableWithClass(class string) AutoTableOption {
|
||||
return func(c *atConfig) { c.class = class }
|
||||
}
|
||||
|
||||
// AutoTable renders the table shell for the given columns and rows. Sort state,
|
||||
// loading, and pagination are plain value props supplied via opts (reactive
|
||||
// accessors collapse). See the file-level NOTE for out-of-scope features.
|
||||
func AutoTable(cols []AutoTableColumn, rows []any, opts ...AutoTableOption) *vdom.VNode {
|
||||
cfg := &atConfig{}
|
||||
for _, o := range opts {
|
||||
o(cfg)
|
||||
}
|
||||
|
||||
tableCls := cx("border-collapse", TBL_BASE)
|
||||
if !cfg.tableLayoutAuto {
|
||||
tableCls = cx(tableCls, "table-fixed")
|
||||
}
|
||||
|
||||
containerCls := TBL_CONTAINER
|
||||
if cfg.surroundingBorder {
|
||||
containerCls = cx(containerCls, "border border-neutral-300")
|
||||
}
|
||||
if cfg.shadow {
|
||||
containerCls = cx(containerCls, "shadow-sm")
|
||||
}
|
||||
|
||||
table := vdom.El("table",
|
||||
vdom.Attr("class", tableCls),
|
||||
atRenderHead(cols, cfg),
|
||||
atRenderBody(cols, rows, cfg),
|
||||
)
|
||||
|
||||
container := vdom.El("div", vdom.Attr("class", containerCls),
|
||||
vdom.El("div", vdom.Attr("class", TBL_WRAPPER), table),
|
||||
)
|
||||
if footer := atRenderPagination(cfg); footer != nil {
|
||||
container.Children = append(container.Children, footer)
|
||||
}
|
||||
|
||||
return vdom.El("div",
|
||||
vdom.Attr("class", cx("min-w-0 w-full max-w-full", cfg.class)),
|
||||
container,
|
||||
)
|
||||
}
|
||||
|
||||
// atRenderHead builds the <thead> with one header <tr>.
|
||||
func atRenderHead(cols []AutoTableColumn, cfg *atConfig) *vdom.VNode {
|
||||
headerColor := HEADER_COLOR_CLS[cfg.color]
|
||||
headerPadding := HEADER_PADDING_CLS[cfg.size]
|
||||
|
||||
tr := vdom.El("tr")
|
||||
if len(cols) == 0 {
|
||||
tr.Children = append(tr.Children,
|
||||
vdom.El("th", vdom.Attr("class", cx(headerPadding, headerColor)), vdom.Text(" ")))
|
||||
}
|
||||
for i, col := range cols {
|
||||
tr.Children = append(tr.Children, atRenderHeaderCell(col, i, cfg, headerColor, headerPadding))
|
||||
}
|
||||
return vdom.El("thead", vdom.Attr("class", atTheadCls), tr)
|
||||
}
|
||||
|
||||
// atRenderHeaderCell builds one <th>. Drag/resize handles are out of scope.
|
||||
func atRenderHeaderCell(col AutoTableColumn, displayIdx int, cfg *atConfig, headerColor, headerPadding string) *vdom.VNode {
|
||||
pos := col.DisplayPosition
|
||||
posCls := POS_CLS[pos]
|
||||
|
||||
thCls := cx(headerPadding, headerColor, col.WidthClass, posCls)
|
||||
if cfg.headerBorderY && displayIdx > 0 {
|
||||
thCls = cx(thCls, "border-l border-l-neutral-300")
|
||||
}
|
||||
if col.Sortable {
|
||||
thCls = cx(thCls, "cursor-pointer", atHeaderSortHoverCls[cfg.color])
|
||||
}
|
||||
thCls = cx(thCls, col.HeaderClasses)
|
||||
|
||||
mods := []vdom.Mod{vdom.Attr("class", thCls)}
|
||||
if col.Sortable {
|
||||
sortID := col.SortIdentifier
|
||||
if sortID != "" && cfg.onSort != nil {
|
||||
mods = append(mods, vdom.On(vdom.EVENT_CLICK, func() { cfg.onSort(sortID) }))
|
||||
}
|
||||
}
|
||||
|
||||
// Inner content: label (grows) + sort caret.
|
||||
inner := vdom.El("div", vdom.Attr("class", cx(HEADER_INNER_BASE, HEADER_INNER_POS[pos])),
|
||||
vdom.El("div", vdom.Attr("class", cx("grow text-sm", HEADER_TEXT_CLS[cfg.color])), vdom.Text(col.DisplayName)),
|
||||
)
|
||||
if col.Sortable {
|
||||
iconWrap := vdom.El("div", vdom.Attr("class", cx(atSortIconWrap, "w-4 text-center", atHeaderSortIconCls[cfg.color])))
|
||||
if col.SortIdentifier != "" && cfg.sortIdentifier == col.SortIdentifier {
|
||||
// caret-up/caret-down are not in the default icon registry, so they
|
||||
// render as empty boxes until an app registers them (see Icons.go).
|
||||
if cfg.sortDescending {
|
||||
iconWrap.Children = append(iconWrap.Children, Icon("caret-down", 16, ""))
|
||||
} else {
|
||||
iconWrap.Children = append(iconWrap.Children, Icon("caret-up", 16, ""))
|
||||
}
|
||||
}
|
||||
inner.Children = append(inner.Children, iconWrap)
|
||||
}
|
||||
|
||||
content := vdom.El("div", vdom.Attr("class", HEADER_CONTENT), inner)
|
||||
mods = append(mods, content)
|
||||
return vdom.El("th", mods...)
|
||||
}
|
||||
|
||||
// atRenderBody builds the <tbody> with its loading / error / empty / data states.
|
||||
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")
|
||||
}
|
||||
|
||||
colspan := len(cols)
|
||||
if colspan == 0 {
|
||||
colspan = 1
|
||||
}
|
||||
|
||||
tbody := vdom.El("tbody", vdom.Attr("class", bodyCls))
|
||||
|
||||
switch {
|
||||
case cfg.loading:
|
||||
// NOTE: the TSX randomizes each skeleton's width; a fixed width is used
|
||||
// here (no measurement/randomness in the neutral runtime).
|
||||
for r := 0; r < 5; r++ {
|
||||
tr := vdom.El("tr")
|
||||
if cfg.alternate && r%2 == 1 {
|
||||
tr.Attrs["class"] = "bg-neutral-100"
|
||||
}
|
||||
for range cols {
|
||||
tr.Children = append(tr.Children, vdom.El("td",
|
||||
vdom.El("div", vdom.Attr("class", atSkeleton), vdom.Attr("style", "width: 70%")),
|
||||
))
|
||||
}
|
||||
tbody.Children = append(tbody.Children, tr)
|
||||
}
|
||||
case cfg.errorMsg != "":
|
||||
tbody.Children = append(tbody.Children, vdom.El("tr",
|
||||
vdom.El("td", vdom.Attr("colspan", strconv.Itoa(colspan)), vdom.Attr("class", atErrorCell),
|
||||
vdom.Text("Error: "+cfg.errorMsg)),
|
||||
))
|
||||
case len(cols) == 0:
|
||||
tbody.Children = append(tbody.Children, vdom.El("tr",
|
||||
vdom.El("td", vdom.Attr("colspan", strconv.Itoa(colspan)), vdom.Attr("class", atEmptyCell),
|
||||
vdom.Text("No columns selected.")),
|
||||
))
|
||||
case len(rows) == 0:
|
||||
tbody.Children = append(tbody.Children, vdom.El("tr",
|
||||
vdom.El("td", vdom.Attr("colspan", strconv.Itoa(colspan)),
|
||||
vdom.Text(pick(cfg.emptyMessage, "No entries found."))),
|
||||
))
|
||||
default:
|
||||
hoverCls := ""
|
||||
if cfg.hover {
|
||||
hoverCls = atRowHoverCls[cfg.color]
|
||||
}
|
||||
for rowIdx, row := range rows {
|
||||
isLast := rowIdx == len(rows)-1
|
||||
rowCls := ""
|
||||
if cfg.alternate && rowIdx%2 == 1 {
|
||||
rowCls = cx(rowCls, "bg-neutral-100")
|
||||
}
|
||||
rowCls = cx(rowCls, hoverCls)
|
||||
if cfg.borderX && !isLast {
|
||||
rowCls = cx(rowCls, "border-b border-neutral-300")
|
||||
}
|
||||
|
||||
tr := vdom.El("tr")
|
||||
if rowCls != "" {
|
||||
tr.Attrs["class"] = rowCls
|
||||
}
|
||||
for _, col := range cols {
|
||||
tr.Children = append(tr.Children, atRenderCell(col, row))
|
||||
}
|
||||
tbody.Children = append(tbody.Children, tr)
|
||||
}
|
||||
}
|
||||
return tbody
|
||||
}
|
||||
|
||||
// atRenderCell renders a column's cell for a row. The cell func returns the full
|
||||
// <td>; if nil, an empty aligned <td> is produced.
|
||||
func atRenderCell(col AutoTableColumn, row any) *vdom.VNode {
|
||||
if col.Cell != nil {
|
||||
if td := col.Cell(row); td != nil {
|
||||
return td
|
||||
}
|
||||
}
|
||||
return vdom.El("td", vdom.Attr("class", POS_CLS[col.DisplayPosition]))
|
||||
}
|
||||
|
||||
// atRenderPagination builds the display-only pagination bar, or nil when there is
|
||||
// nothing to show. Page/items-per-page changes are surfaced via callbacks.
|
||||
func atRenderPagination(cfg *atConfig) *vdom.VNode {
|
||||
if cfg.pagination == nil || cfg.hidePagination {
|
||||
return nil
|
||||
}
|
||||
p := cfg.pagination
|
||||
|
||||
info := vdom.El("div", vdom.Attr("class", atPaginationInfo),
|
||||
vdom.El("b", vdom.Attr("class", "leading-none"), Icon("list-ol", 16, "")),
|
||||
vdom.El("span", vdom.Attr("class", "ml-3"),
|
||||
vdom.Text(strconv.Itoa(p.ViewRangeLower)+"-"+strconv.Itoa(p.ViewRangeUpper)+" of "+strconv.Itoa(p.TotalItems))),
|
||||
)
|
||||
|
||||
// NOTE: the TSX uses the ported FormSelect; a bare <select> is used here.
|
||||
sel := vdom.El("select", vdom.Attr("class", "mr-5"))
|
||||
for _, n := range []int{5, 10, 25, 50, 100} {
|
||||
optMods := []vdom.Mod{vdom.Attr("value", strconv.Itoa(n)), vdom.Text(strconv.Itoa(n))}
|
||||
if n == p.MaxItemsPerPage {
|
||||
optMods = append(optMods, vdom.Attr("selected", "selected"))
|
||||
}
|
||||
sel.Children = append(sel.Children, vdom.El("option", optMods...))
|
||||
}
|
||||
if cfg.onItemsPerPage != nil {
|
||||
sel.Events[vdom.EVENT_CHANGE] = func(e vdom.Event) {
|
||||
if n, err := strconv.Atoi(e.Value()); err == nil {
|
||||
cfg.onItemsPerPage(n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
page := func(to int) func() {
|
||||
return func() {
|
||||
if cfg.onPageChange != nil {
|
||||
cfg.onPageChange(to)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
controls := vdom.El("div", vdom.Attr("class", atPaginationControls),
|
||||
vdom.El("div", vdom.Attr("class", atPaginationLabel), vdom.Text("Items per page:")),
|
||||
sel,
|
||||
atPaginationButton(page(1), p.CurrentPage <= 1, Icon("angles-left", 16, "")),
|
||||
atPaginationButton(page(p.CurrentPage-1), p.CurrentPage <= 1, Icon("chevron-left", 16, "")),
|
||||
vdom.El("div", vdom.Attr("class", atPaginationPage),
|
||||
vdom.Text("Page "+strconv.Itoa(p.CurrentPage)+" of "+strconv.Itoa(p.TotalPages))),
|
||||
atPaginationButton(page(p.CurrentPage+1), p.CurrentPage >= p.TotalPages, Icon("chevron-right", 16, "")),
|
||||
atPaginationButton(page(p.TotalPages), p.CurrentPage >= p.TotalPages, Icon("angles-right", 16, "")),
|
||||
)
|
||||
|
||||
return vdom.El("div", vdom.Attr("class", cx(atPaginationBase, atPaginationPaddingCls[cfg.size])),
|
||||
vdom.El("div", vdom.Attr("class", "flex items-center"), info),
|
||||
controls,
|
||||
)
|
||||
}
|
||||
|
||||
// atPaginationButton is one pagination control button (mirrors TSX PaginationButton).
|
||||
func atPaginationButton(onClick func(), disabled bool, child *vdom.VNode) *vdom.VNode {
|
||||
mods := []vdom.Mod{vdom.Attr("class", atPaginationBtn)}
|
||||
if disabled {
|
||||
mods = append(mods, vdom.Attr("disabled", "disabled"))
|
||||
} else if onClick != nil {
|
||||
mods = append(mods, vdom.On(vdom.EVENT_CLICK, onClick))
|
||||
}
|
||||
if child != nil {
|
||||
mods = append(mods, child)
|
||||
}
|
||||
return vdom.El("button", mods...)
|
||||
}
|
||||
|
||||
// AutoTableTdLeft / AutoTableTdRight / AutoTableTdCenter build an aligned <td>,
|
||||
// convenient for AutoTableColumn.Cell funcs (mirror the TSX TdLeft/Right/Center).
|
||||
func AutoTableTdLeft(class string, children ...*vdom.VNode) *vdom.VNode {
|
||||
return vdom.El("td", kids([]vdom.Mod{vdom.Attr("class", cx("text-left", class))}, children)...)
|
||||
}
|
||||
|
||||
func AutoTableTdRight(class string, children ...*vdom.VNode) *vdom.VNode {
|
||||
return vdom.El("td", kids([]vdom.Mod{vdom.Attr("class", cx("text-right", class))}, children)...)
|
||||
}
|
||||
|
||||
func AutoTableTdCenter(class string, children ...*vdom.VNode) *vdom.VNode {
|
||||
return vdom.El("td", kids([]vdom.Mod{vdom.Attr("class", cx("text-center", class))}, children)...)
|
||||
}
|
||||
71
go/webui/badges.go
Normal file
71
go/webui/badges.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package webui
|
||||
|
||||
import "kjol/vdom"
|
||||
|
||||
// Port of web/kit/Badges.tsx.
|
||||
|
||||
const (
|
||||
BadgeGreen = "green"
|
||||
BadgeRed = "red"
|
||||
BadgeBlue = "blue"
|
||||
BadgeAmber = "amber"
|
||||
BadgeNeutral = "neutral"
|
||||
BadgeMuted = "muted"
|
||||
)
|
||||
|
||||
const badgeBase = "inline-flex items-center gap-1 text-xs font-semibold py-0.5 px-2 rounded-default whitespace-nowrap"
|
||||
|
||||
var badgeColors = map[string]string{
|
||||
"green": "text-white bg-green-700",
|
||||
"red": "text-white bg-red-700",
|
||||
"blue": "text-white bg-sky-800",
|
||||
"amber": "text-white bg-amber-700",
|
||||
"neutral": "text-white bg-neutral-500",
|
||||
"muted": "text-neutral-400 bg-transparent",
|
||||
}
|
||||
|
||||
// BadgeProps configures Badge. When OnClick is set the badge renders as a
|
||||
// <button> (same visuals, interactive).
|
||||
type BadgeProps struct {
|
||||
Color string
|
||||
Pill bool
|
||||
OnClick func()
|
||||
Disabled bool
|
||||
Title string
|
||||
Class string
|
||||
}
|
||||
|
||||
func badgeClass(p BadgeProps) string {
|
||||
c := badgeBase
|
||||
if p.Pill {
|
||||
c = cx(c, "rounded-full")
|
||||
}
|
||||
cc := badgeColors[p.Color]
|
||||
if cc == "" {
|
||||
cc = badgeColors["neutral"]
|
||||
}
|
||||
c = cx(c, cc)
|
||||
if p.OnClick != nil {
|
||||
c = cx(c, "cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed border-0")
|
||||
}
|
||||
return cx(c, p.Class)
|
||||
}
|
||||
|
||||
// Badge renders a small status pill (or interactive button when OnClick is set).
|
||||
func Badge(p BadgeProps, children ...*vdom.VNode) *vdom.VNode {
|
||||
if p.OnClick != nil {
|
||||
mods := []vdom.Mod{vdom.Attr("type", "button"), vdom.Attr("class", badgeClass(p)), vdom.On(vdom.EVENT_CLICK, p.OnClick)}
|
||||
if p.Disabled {
|
||||
mods = append(mods, vdom.Attr("disabled", "disabled"))
|
||||
}
|
||||
if p.Title != "" {
|
||||
mods = append(mods, vdom.Attr("title", p.Title))
|
||||
}
|
||||
return vdom.El("button", kids(mods, children)...)
|
||||
}
|
||||
mods := []vdom.Mod{vdom.Attr("class", badgeClass(p))}
|
||||
if p.Title != "" {
|
||||
mods = append(mods, vdom.Attr("title", p.Title))
|
||||
}
|
||||
return vdom.El("span", kids(mods, children)...)
|
||||
}
|
||||
214
go/webui/buttons.go
Normal file
214
go/webui/buttons.go
Normal file
@@ -0,0 +1,214 @@
|
||||
package webui
|
||||
|
||||
import "kjol/vdom"
|
||||
|
||||
// Port of web/kit/Buttons.tsx.
|
||||
|
||||
const (
|
||||
ButtonNeutral = "neutral"
|
||||
ButtonWhite = "white"
|
||||
ButtonLightNeutral = "light-neutral"
|
||||
ButtonBlue = "blue"
|
||||
ButtonDarkBlue = "dark-blue"
|
||||
ButtonGreen = "green"
|
||||
ButtonDarkGreen = "dark-green"
|
||||
ButtonRed = "red"
|
||||
ButtonDarkRed = "dark-red"
|
||||
ButtonYellow = "yellow"
|
||||
ButtonOrange = "orange"
|
||||
ButtonPrimary = "primary"
|
||||
ButtonSecondary = "secondary"
|
||||
ButtonGhost = "ghost"
|
||||
)
|
||||
|
||||
const btnBase = "inline-flex items-center justify-center gap-2 cursor-pointer text-sm font-normal rounded-default transition disabled:opacity-50 disabled:cursor-not-allowed focus-visible:outline-2 focus-visible:outline-current focus-visible:outline-offset-2"
|
||||
|
||||
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",
|
||||
"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",
|
||||
"dark-green": "shadow-xs bg-green-900 text-white hover:bg-green-950",
|
||||
"red": "shadow-xs bg-red-700 text-white hover:bg-red-800",
|
||||
"dark-red": "shadow-xs bg-red-900 text-white hover:bg-red-950",
|
||||
"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",
|
||||
}
|
||||
|
||||
var btnOutlineColors = map[string]string{
|
||||
"neutral": "text-neutral-700",
|
||||
"white": "text-neutral-300",
|
||||
"light-neutral": "text-neutral-300",
|
||||
"blue": "text-sky-700",
|
||||
"dark-blue": "text-sky-900",
|
||||
"green": "text-green-700",
|
||||
"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",
|
||||
}
|
||||
|
||||
const btnOutlineBase = "bg-transparent shadow-[inset_0_0_0_1px_currentColor] hover:shadow-[inset_0_0_0_2px_currentColor]"
|
||||
|
||||
// ButtonProps configures Button. Icon is an icon name (see webui.Icon); Text is
|
||||
// the label. If neither Text nor children are given the button is icon-only.
|
||||
type ButtonProps struct {
|
||||
Color string
|
||||
Outline bool
|
||||
Small bool
|
||||
Icon string
|
||||
Text string
|
||||
Type string
|
||||
Disabled bool
|
||||
Title string
|
||||
Class string
|
||||
OnClick func()
|
||||
}
|
||||
|
||||
func buttonClass(p ButtonProps) string {
|
||||
c := btnBase
|
||||
if p.Outline {
|
||||
oc := btnOutlineColors[p.Color]
|
||||
if oc == "" {
|
||||
oc = btnOutlineColors["neutral"]
|
||||
}
|
||||
c = cx(c, btnOutlineBase, oc)
|
||||
} else {
|
||||
cc := btnColors[p.Color]
|
||||
if cc == "" {
|
||||
cc = btnColors["neutral"]
|
||||
}
|
||||
c = cx(c, cc)
|
||||
}
|
||||
hasText := p.Text != ""
|
||||
switch {
|
||||
case p.Small && p.Icon != "":
|
||||
c = cx(c, "py-1 px-3")
|
||||
case p.Small:
|
||||
c = cx(c, "py-1 px-4")
|
||||
case p.Icon != "" && !hasText:
|
||||
c = cx(c, "py-2 px-3")
|
||||
case p.Icon != "":
|
||||
c = cx(c, "py-2 px-5")
|
||||
default:
|
||||
c = cx(c, "py-2 px-8")
|
||||
}
|
||||
return cx(c, p.Class)
|
||||
}
|
||||
|
||||
// Button renders a styled <button>. Extra children render after any Icon/Text.
|
||||
func Button(p ButtonProps, children ...*vdom.VNode) *vdom.VNode {
|
||||
mods := []vdom.Mod{
|
||||
vdom.Attr("type", pick(p.Type, "button")),
|
||||
vdom.Attr("class", buttonClass(p)),
|
||||
}
|
||||
if p.Disabled {
|
||||
mods = append(mods, vdom.Attr("disabled", "disabled"))
|
||||
}
|
||||
if p.Title != "" {
|
||||
mods = append(mods, vdom.Attr("title", p.Title))
|
||||
}
|
||||
if p.OnClick != nil {
|
||||
mods = append(mods, vdom.On(vdom.EVENT_CLICK, p.OnClick))
|
||||
}
|
||||
if p.Icon != "" {
|
||||
mods = append(mods, Icon(p.Icon, iconSize(p.Small), ""))
|
||||
}
|
||||
if p.Text != "" {
|
||||
mods = append(mods, vdom.Text(p.Text))
|
||||
}
|
||||
mods = kids(mods, children)
|
||||
return vdom.El("button", mods...)
|
||||
}
|
||||
|
||||
func iconSize(small bool) int {
|
||||
if small {
|
||||
return 14
|
||||
}
|
||||
return 16
|
||||
}
|
||||
|
||||
// ButtonLink is a text-styled link button (blue).
|
||||
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"),
|
||||
}
|
||||
if onClick != nil {
|
||||
mods = append(mods, vdom.On(vdom.EVENT_CLICK, onClick))
|
||||
}
|
||||
return vdom.El("button", kids(mods, children)...)
|
||||
}
|
||||
|
||||
// ButtonLinkRed is the red variant of ButtonLink.
|
||||
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"),
|
||||
}
|
||||
if onClick != nil {
|
||||
mods = append(mods, vdom.On(vdom.EVENT_CLICK, onClick))
|
||||
}
|
||||
return vdom.El("button", kids(mods, children)...)
|
||||
}
|
||||
|
||||
// SegmentedButtonOption is one option in a SegmentedButtons group.
|
||||
type SegmentedButtonOption struct {
|
||||
Value string
|
||||
Label string
|
||||
Icon string
|
||||
}
|
||||
|
||||
// SegmentedButtons renders a pill group of mutually-exclusive options; `value`
|
||||
// is the selected option's value, `onChange` receives the clicked value.
|
||||
func SegmentedButtons(options []SegmentedButtonOption, value string, onChange func(string), small bool, class string) *vdom.VNode {
|
||||
sizeCls := "py-1 px-3 text-sm"
|
||||
if small {
|
||||
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"
|
||||
|
||||
mods := []vdom.Mod{vdom.Attr("class", cx("flex items-center gap-0.5 rounded-md bg-neutral-100 p-0.5", class))}
|
||||
for _, opt := range options {
|
||||
state := inactiveCls
|
||||
if opt.Value == value {
|
||||
state = activeCls
|
||||
}
|
||||
v := opt.Value
|
||||
btnMods := []vdom.Mod{
|
||||
vdom.Attr("type", "button"),
|
||||
vdom.Attr("class", cx(baseCls, "rounded-default", sizeCls, state)),
|
||||
vdom.Attr("title", opt.Label),
|
||||
vdom.On(vdom.EVENT_CLICK, func() { onChange(v) }),
|
||||
}
|
||||
if opt.Icon != "" {
|
||||
btnMods = append(btnMods, Icon(opt.Icon, 12, ""))
|
||||
}
|
||||
btnMods = append(btnMods, vdom.El("span", vdom.Text(opt.Label)))
|
||||
mods = append(mods, vdom.El("button", btnMods...))
|
||||
}
|
||||
return vdom.El("div", mods...)
|
||||
}
|
||||
|
||||
// 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"
|
||||
if onDark {
|
||||
color = "text-text-on-dark-muted hover:text-text-on-dark"
|
||||
}
|
||||
return vdom.El("a",
|
||||
vdom.Attr("href", href),
|
||||
vdom.Attr("class", cx("inline-flex items-center gap-1 text-sm no-underline", color)),
|
||||
Icon("chevron-left", 16, ""),
|
||||
vdom.Text(text),
|
||||
)
|
||||
}
|
||||
322
go/webui/calendar.go
Normal file
322
go/webui/calendar.go
Normal file
@@ -0,0 +1,322 @@
|
||||
// Port of web/kit/Calendar.tsx. Date math uses the stdlib time package.
|
||||
package webui
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"kjol/vdom"
|
||||
)
|
||||
|
||||
// Day-of-week and month labels (module-level in the TSX). Prefixed to stay
|
||||
// private to the Calendar/DatePicker files that share them.
|
||||
var calDays = []string{"Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"}
|
||||
|
||||
var calMonths = []string{
|
||||
"January", "February", "March", "April", "May", "June",
|
||||
"July", "August", "September", "October", "November", "December",
|
||||
}
|
||||
|
||||
var calMonthsShort = []string{
|
||||
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
||||
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
|
||||
}
|
||||
|
||||
// -- Shared Tailwind class constants (also used by datepicker.go). These are
|
||||
// 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 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 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 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 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"
|
||||
|
||||
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 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"
|
||||
|
||||
// Calendar variants.
|
||||
const (
|
||||
CalendarVariantPicker = "picker"
|
||||
CalendarVariantMonth = "month"
|
||||
)
|
||||
|
||||
// calDaysInMonth returns the number of days in month (day 0 of the next month).
|
||||
func calDaysInMonth(year int, month time.Month) int {
|
||||
return time.Date(year, month+1, 0, 0, 0, 0, 0, time.UTC).Day()
|
||||
}
|
||||
|
||||
// calFirstWeekday returns the weekday of the 1st (Sunday=0 .. Saturday=6),
|
||||
// matching JS getDay().
|
||||
func calFirstWeekday(year int, month time.Month) int {
|
||||
return int(time.Date(year, month, 1, 0, 0, 0, 0, time.UTC).Weekday())
|
||||
}
|
||||
|
||||
// calDateKey formats a date as the "YYYY-MM-DD" key the callbacks emit.
|
||||
func calDateKey(t time.Time) string { return t.Format("2006-01-02") }
|
||||
|
||||
// calParseKey strictly parses a "YYYY-MM-DD" key.
|
||||
//
|
||||
// NOTE: the TSX passes selected/viewMonth through `new Date(...)`, which accepts
|
||||
// many free-form strings; the Go API is documented as "YYYY-MM-DD" so parsing is
|
||||
// narrowed to that layout.
|
||||
func calParseKey(s string) (time.Time, bool) {
|
||||
if s == "" {
|
||||
return time.Time{}, false
|
||||
}
|
||||
t, err := time.Parse("2006-01-02", s)
|
||||
if err != nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return t, true
|
||||
}
|
||||
|
||||
func calSameDay(a, b time.Time) bool {
|
||||
return a.Year() == b.Year() && a.Month() == b.Month() && a.Day() == b.Day()
|
||||
}
|
||||
|
||||
// calCell is one grid cell: a day, or an empty leading blank.
|
||||
type calCell struct {
|
||||
date time.Time
|
||||
empty bool
|
||||
}
|
||||
|
||||
// calMonthCells builds the padded month grid (leading blanks + each day).
|
||||
func calMonthCells(year int, month time.Month) []calCell {
|
||||
n := calDaysInMonth(year, month)
|
||||
first := calFirstWeekday(year, month)
|
||||
cells := make([]calCell, 0, first+n)
|
||||
for i := 0; i < first; i++ {
|
||||
cells = append(cells, calCell{empty: true})
|
||||
}
|
||||
for d := 1; d <= n; d++ {
|
||||
cells = append(cells, calCell{date: time.Date(year, month, d, 0, 0, 0, 0, time.UTC)})
|
||||
}
|
||||
return cells
|
||||
}
|
||||
|
||||
// calWeekdaysRow renders the Su..Sa header row for the given grid/cell classes.
|
||||
func calWeekdaysRow(rowCls, cellCls string) *vdom.VNode {
|
||||
mods := []vdom.Mod{vdom.Attr("class", rowCls)}
|
||||
for _, d := range calDays {
|
||||
mods = append(mods, vdom.El("div", vdom.Attr("class", cellCls), vdom.Text(d)))
|
||||
}
|
||||
return vdom.El("div", mods...)
|
||||
}
|
||||
|
||||
func calDayClassPicker(empty, selected, today bool) string {
|
||||
c := calDayPickerBase
|
||||
if empty {
|
||||
c = cx(c, "cursor-default")
|
||||
} else {
|
||||
c = cx(c, "hover:bg-neutral-100")
|
||||
}
|
||||
if today {
|
||||
c = cx(c, "font-bold text-primary")
|
||||
}
|
||||
if selected {
|
||||
c = cx(c, "!bg-primary !text-white")
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// calDayClassMonth mirrors the month-variant class logic. idx%7==6 (last column)
|
||||
// drops the right border since the Tailwind compiler can't do [&:nth-child(7n)].
|
||||
func calDayClassMonth(idx int, empty, selected bool) string {
|
||||
c := calDayMonthBase
|
||||
if idx%7 == 6 {
|
||||
c = cx(c, "border-r-0")
|
||||
}
|
||||
if empty {
|
||||
c = cx(c, "bg-neutral-50 cursor-default")
|
||||
} else {
|
||||
c = cx(c, "hover:bg-neutral-50")
|
||||
}
|
||||
if selected {
|
||||
c = cx(c, "bg-primary/10 text-text-body")
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func calDayNumberClassMonth(today bool) string {
|
||||
if today {
|
||||
return "bg-primary text-white rounded-full w-6 h-6 inline-flex items-center justify-center p-0 self-end text-sm font-semibold"
|
||||
}
|
||||
return "text-sm font-semibold text-text-muted self-end px-1 py-0.5"
|
||||
}
|
||||
|
||||
// calPickerDaysGrid renders the picker-variant day grid. renderDay is optional.
|
||||
// Shared by Calendar (picker) and the DatePicker date-of-birth calendar.
|
||||
func calPickerDaysGrid(view, sel time.Time, hasSel bool, now time.Time, onSelect func(string), renderDay func(string, time.Time) *vdom.VNode) *vdom.VNode {
|
||||
cells := calMonthCells(view.Year(), view.Month())
|
||||
mods := []vdom.Mod{vdom.Attr("class", calDaysPicker)}
|
||||
for _, cell := range cells {
|
||||
selected := hasSel && !cell.empty && calSameDay(cell.date, sel)
|
||||
today := !cell.empty && calSameDay(cell.date, now)
|
||||
btnMods := []vdom.Mod{
|
||||
vdom.Attr("type", "button"),
|
||||
vdom.Attr("class", calDayClassPicker(cell.empty, selected, today)),
|
||||
}
|
||||
if cell.empty {
|
||||
btnMods = append(btnMods, vdom.Attr("disabled", "disabled"))
|
||||
} else {
|
||||
d := cell.date
|
||||
btnMods = append(btnMods, vdom.On(vdom.EVENT_CLICK, func() {
|
||||
if onSelect != nil {
|
||||
onSelect(calDateKey(d))
|
||||
}
|
||||
}))
|
||||
}
|
||||
num := ""
|
||||
if !cell.empty {
|
||||
num = strconv.Itoa(cell.date.Day())
|
||||
}
|
||||
btnMods = append(btnMods, vdom.El("span", vdom.Attr("class", "leading-none"), vdom.Text(num)))
|
||||
if !cell.empty && renderDay != nil {
|
||||
if extra := renderDay(calDateKey(cell.date), cell.date); extra != nil {
|
||||
btnMods = append(btnMods, extra)
|
||||
}
|
||||
}
|
||||
mods = append(mods, vdom.El("button", btnMods...))
|
||||
}
|
||||
return vdom.El("div", mods...)
|
||||
}
|
||||
|
||||
// calMonthDaysGrid renders the month-variant day grid.
|
||||
func calMonthDaysGrid(view, sel time.Time, hasSel bool, now time.Time, onSelect func(string), renderDay func(string, time.Time) *vdom.VNode) *vdom.VNode {
|
||||
cells := calMonthCells(view.Year(), view.Month())
|
||||
mods := []vdom.Mod{vdom.Attr("class", calDaysMonth)}
|
||||
for idx, cell := range cells {
|
||||
selected := hasSel && !cell.empty && calSameDay(cell.date, sel)
|
||||
today := !cell.empty && calSameDay(cell.date, now)
|
||||
btnMods := []vdom.Mod{
|
||||
vdom.Attr("type", "button"),
|
||||
vdom.Attr("class", calDayClassMonth(idx, cell.empty, selected)),
|
||||
}
|
||||
if cell.empty {
|
||||
btnMods = append(btnMods, vdom.Attr("disabled", "disabled"))
|
||||
} else {
|
||||
d := cell.date
|
||||
btnMods = append(btnMods, vdom.On(vdom.EVENT_CLICK, func() {
|
||||
if onSelect != nil {
|
||||
onSelect(calDateKey(d))
|
||||
}
|
||||
}))
|
||||
}
|
||||
num := ""
|
||||
if !cell.empty {
|
||||
num = strconv.Itoa(cell.date.Day())
|
||||
}
|
||||
btnMods = append(btnMods, vdom.El("span", vdom.Attr("class", calDayNumberClassMonth(today)), vdom.Text(num)))
|
||||
if !cell.empty && renderDay != nil {
|
||||
if extra := renderDay(calDateKey(cell.date), cell.date); extra != nil {
|
||||
btnMods = append(btnMods, extra)
|
||||
}
|
||||
}
|
||||
mods = append(mods, vdom.El("button", btnMods...))
|
||||
}
|
||||
return vdom.El("div", mods...)
|
||||
}
|
||||
|
||||
// CalendarProps configures Calendar. It is a controlled component: the visible
|
||||
// month is a plain value (ViewMonth) driven by the OnNavigate callback, and the
|
||||
// selection is a "YYYY-MM-DD" key driven by OnSelect.
|
||||
//
|
||||
// NOTE: the TSX kept ViewMonth in an internal signal synced from props via
|
||||
// effects, plus attr:key / _reset re-render hacks. Those have no equivalent in
|
||||
// the neutral runtime; the month is modeled as an explicit prop + callback.
|
||||
type CalendarProps struct {
|
||||
Selected string // selected date "YYYY-MM-DD" (empty = none)
|
||||
ViewMonth time.Time // visible month (any day within it); zero => Selected's month, else today
|
||||
Variant string // "picker" (default) or "month"
|
||||
OnSelect func(key string) // clicked day, as "YYYY-MM-DD"
|
||||
OnNavigate func(month time.Time) // first-of-month requested by the prev/next buttons
|
||||
RenderDay func(key string, date time.Time) *vdom.VNode // optional per-day content
|
||||
Class string
|
||||
}
|
||||
|
||||
// Calendar renders a month grid in either the compact "picker" layout or the
|
||||
// large "month" layout.
|
||||
func Calendar(p CalendarProps) *vdom.VNode {
|
||||
now := time.Now()
|
||||
sel, hasSel := calParseKey(p.Selected)
|
||||
|
||||
view := p.ViewMonth
|
||||
if view.IsZero() {
|
||||
if hasSel {
|
||||
view = sel
|
||||
} else {
|
||||
view = now
|
||||
}
|
||||
}
|
||||
|
||||
isMonth := p.Variant == CalendarVariantMonth
|
||||
|
||||
rootCls := calPickerRoot
|
||||
headerCls := calHeaderPicker
|
||||
myCls := calMyPicker
|
||||
weekdaysCls := calWeekdaysPicker
|
||||
weekdayCls := calWeekdayPicker
|
||||
if isMonth {
|
||||
rootCls = calMonthRoot
|
||||
headerCls = calHeaderMonth
|
||||
myCls = calMyMonth
|
||||
weekdaysCls = calWeekdaysMonth
|
||||
weekdayCls = calWeekdayMonth
|
||||
}
|
||||
|
||||
prev := time.Date(view.Year(), view.Month()-1, 1, 0, 0, 0, 0, time.UTC)
|
||||
next := time.Date(view.Year(), view.Month()+1, 1, 0, 0, 0, 0, time.UTC)
|
||||
navigate := func(m time.Time) func() {
|
||||
return func() {
|
||||
if p.OnNavigate != nil {
|
||||
p.OnNavigate(m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
header := vdom.El("div", vdom.Attr("class", headerCls),
|
||||
vdom.El("button",
|
||||
vdom.Attr("type", "button"),
|
||||
vdom.Attr("class", calNavBtn),
|
||||
vdom.On(vdom.EVENT_CLICK, navigate(prev)),
|
||||
Icon("chevron-left", 16, ""),
|
||||
),
|
||||
vdom.El("span", vdom.Attr("class", myCls),
|
||||
vdom.Text(calMonths[int(view.Month())-1]+" "+strconv.Itoa(view.Year()))),
|
||||
vdom.El("button",
|
||||
vdom.Attr("type", "button"),
|
||||
vdom.Attr("class", calNavBtn),
|
||||
vdom.On(vdom.EVENT_CLICK, navigate(next)),
|
||||
Icon("chevron-right", 16, ""),
|
||||
),
|
||||
)
|
||||
|
||||
var daysGrid *vdom.VNode
|
||||
if isMonth {
|
||||
daysGrid = calMonthDaysGrid(view, sel, hasSel, now, p.OnSelect, p.RenderDay)
|
||||
} else {
|
||||
daysGrid = calPickerDaysGrid(view, sel, hasSel, now, p.OnSelect, p.RenderDay)
|
||||
}
|
||||
|
||||
return vdom.El("div",
|
||||
vdom.Attr("class", cx(rootCls, p.Class)),
|
||||
header,
|
||||
calWeekdaysRow(weekdaysCls, weekdayCls),
|
||||
daysGrid,
|
||||
)
|
||||
}
|
||||
73
go/webui/cards.go
Normal file
73
go/webui/cards.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package webui
|
||||
|
||||
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 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"
|
||||
|
||||
// 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:[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)]"
|
||||
|
||||
func cardDiv(base, class string, children []*vdom.VNode) *vdom.VNode {
|
||||
return vdom.El("div", kids([]vdom.Mod{vdom.Attr("class", cx(base, class))}, children)...)
|
||||
}
|
||||
|
||||
// Card is the standard padded, flex-grow card.
|
||||
func Card(class string, children ...*vdom.VNode) *vdom.VNode {
|
||||
return cardDiv(cardWithPadding, class, children)
|
||||
}
|
||||
|
||||
// CardNoPadding is a card with no padding and no flex-grow.
|
||||
func CardNoPadding(class string, children ...*vdom.VNode) *vdom.VNode {
|
||||
return cardDiv(cardNoPaddingNoFlex, class, children)
|
||||
}
|
||||
|
||||
// CardNoFlexGrow is a padded card that does not flex-grow.
|
||||
func CardNoFlexGrow(class string, children ...*vdom.VNode) *vdom.VNode {
|
||||
return cardDiv(cardNoFlex, class, children)
|
||||
}
|
||||
|
||||
// BorderCard is a bordered (shadowless) card.
|
||||
func BorderCard(class string, children ...*vdom.VNode) *vdom.VNode {
|
||||
return cardDiv(borderCard, class, children)
|
||||
}
|
||||
|
||||
// BorderCutCornerCard is a card with two notched corners (clip-path).
|
||||
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 cardHeaderHR = "text-neutral-200 mt-1 mb-3"
|
||||
|
||||
// CardHeader renders a card title followed by a divider.
|
||||
func CardHeader(class string, children ...*vdom.VNode) *vdom.VNode {
|
||||
mods := kids([]vdom.Mod{vdom.Attr("class", cx(cardHeader, class))}, children)
|
||||
mods = append(mods, vdom.El("hr", vdom.Attr("class", cardHeaderHR)))
|
||||
return vdom.El("div", mods...)
|
||||
}
|
||||
|
||||
// CardHeaderTextCenter is CardHeader, centered.
|
||||
func CardHeaderTextCenter(class string, children ...*vdom.VNode) *vdom.VNode {
|
||||
mods := kids([]vdom.Mod{vdom.Attr("class", cx(cardHeader, "text-center", class))}, children)
|
||||
mods = append(mods, vdom.El("hr", vdom.Attr("class", cardHeaderHR)))
|
||||
return vdom.El("div", mods...)
|
||||
}
|
||||
|
||||
// CardSubheader renders a smaller secondary heading.
|
||||
func CardSubheader(class string, children ...*vdom.VNode) *vdom.VNode {
|
||||
return vdom.El("div", kids([]vdom.Mod{vdom.Attr("class", cx("text-lg tracking-tight text-black mb-2", class))}, children)...)
|
||||
}
|
||||
|
||||
// CardSpacer is vertical spacing between cards.
|
||||
func CardSpacer() *vdom.VNode { return vdom.El("div", vdom.Attr("class", "mb-6")) }
|
||||
479
go/webui/cellgrid.go
Normal file
479
go/webui/cellgrid.go
Normal file
@@ -0,0 +1,479 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"kjol/vdom"
|
||||
)
|
||||
|
||||
// Port of web/kit/CellGrid.tsx — an editable, sortable spreadsheet-style grid.
|
||||
//
|
||||
// Row model: rows are map[string]any (the clean Go analog of the TSX's `any`
|
||||
// objects with string-keyed fields, so row[col.Key], row[IDField], and conflict
|
||||
// lookups map directly). Column callbacks (Render, SortValue, CellClassFn,
|
||||
// OnClick, Parse) take/return that map so no type assertions are needed.
|
||||
//
|
||||
// NOTE: Several CellGrid behaviors are browser-only and have no equivalent in the
|
||||
// neutral runtime, so they are dropped (the render output and sort/edit/conflict
|
||||
// wiring are preserved):
|
||||
// - Keyboard navigation (Enter/Tab/Arrows/Escape), cell focus & the `selected`
|
||||
// signal, and focusCell/moveSelection: all require live DOM focus()/refs.
|
||||
// - Row-reorder FLIP animation (getBoundingClientRect + Element.animate).
|
||||
// - The imperative CellGridApi `ref` (focusCell/selected/snapshot/animateRows).
|
||||
// Its one pure-logic member, dirty(), is offered as the CellGridDirty helper.
|
||||
// - onFocus/onBlur/blurStamp: conflict sets are recomputed on every render from
|
||||
// the current rows instead (a signal write already re-renders), so duplicate
|
||||
// highlighting stays live without the blur hook.
|
||||
// - localeCompare is approximated by byte-wise strings.Compare.
|
||||
// - The sort caret uses "caret-up"/"caret-down"; those aren't in the default
|
||||
// icon registry, so they render as an empty box unless the app RegisterIcon's
|
||||
// them (the triangle-exclamation conflict marker is registered by default).
|
||||
|
||||
// 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"
|
||||
|
||||
var cgLeadingDigits = regexp.MustCompile(`^\d+`)
|
||||
var cgLeadingFloat = regexp.MustCompile(`^[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?`)
|
||||
|
||||
func cellGridStr(v any) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprint(v)
|
||||
}
|
||||
|
||||
func cellGridColumnSize(width, minWidth string) string {
|
||||
if width != "" {
|
||||
return width
|
||||
}
|
||||
return minWidth
|
||||
}
|
||||
|
||||
func cellGridLeadingInt(s string) (int, bool) {
|
||||
m := cgLeadingDigits.FindString(s)
|
||||
if m == "" {
|
||||
return 0, false
|
||||
}
|
||||
n, err := strconv.Atoi(m)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return n, true
|
||||
}
|
||||
|
||||
func cellGridParseFloat(v any) float64 {
|
||||
m := cgLeadingFloat.FindString(strings.TrimSpace(cellGridStr(v)))
|
||||
if m == "" || m == "+" || m == "-" || m == "." {
|
||||
return 0
|
||||
}
|
||||
f, err := strconv.ParseFloat(m, 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
// CompareRowsGeneric compares a[key] and b[key] the way the TSX comparator does:
|
||||
// empties sort last, "numeric" by leading integer then string tiebreak, "money"
|
||||
// by parsed float, otherwise by string. Returns <0, 0, or >0.
|
||||
func CompareRowsGeneric(a, b map[string]any, key, sortType string) int {
|
||||
av := a[key]
|
||||
bv := b[key]
|
||||
aEmpty := av == nil || av == ""
|
||||
bEmpty := bv == nil || bv == ""
|
||||
if aEmpty && bEmpty {
|
||||
return 0
|
||||
}
|
||||
if aEmpty {
|
||||
return 1
|
||||
}
|
||||
if bEmpty {
|
||||
return -1
|
||||
}
|
||||
switch sortType {
|
||||
case "numeric":
|
||||
as := cellGridStr(av)
|
||||
bs := cellGridStr(bv)
|
||||
an, aok := cellGridLeadingInt(as)
|
||||
bn, bok := cellGridLeadingInt(bs)
|
||||
if aok && bok {
|
||||
if an != bn {
|
||||
if an < bn {
|
||||
return -1
|
||||
}
|
||||
return 1
|
||||
}
|
||||
return strings.Compare(as, bs)
|
||||
}
|
||||
if aok {
|
||||
return -1
|
||||
}
|
||||
if bok {
|
||||
return 1
|
||||
}
|
||||
return strings.Compare(as, bs)
|
||||
case "money":
|
||||
af := cellGridParseFloat(av)
|
||||
bf := cellGridParseFloat(bv)
|
||||
if af < bf {
|
||||
return -1
|
||||
}
|
||||
if af > bf {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
default:
|
||||
return strings.Compare(cellGridStr(av), cellGridStr(bv))
|
||||
}
|
||||
}
|
||||
|
||||
// CellGridColumn defines one grid column. The `cellClass: string | (row)=>string`
|
||||
// TSX union becomes CellClass (static) + CellClassFn (dynamic; wins when set).
|
||||
type CellGridColumn struct {
|
||||
Key string
|
||||
Label string
|
||||
SortKey string
|
||||
SortType string
|
||||
SortValue func(row map[string]any) any
|
||||
Width string
|
||||
MinWidth string
|
||||
HeaderClass string
|
||||
Editable bool
|
||||
ReadOnly bool
|
||||
Render func(row map[string]any) *vdom.VNode
|
||||
CellClass string
|
||||
CellClassFn func(row map[string]any) string
|
||||
OnClick func(row map[string]any)
|
||||
InputMode string
|
||||
Placeholder string
|
||||
Parse func(value string) any
|
||||
}
|
||||
|
||||
// CellGridProps configures CellGrid. SortKey/SortDesc are the current sort state;
|
||||
// SetSortKey/SetSortDesc are called by the header click handler (which toggles
|
||||
// direction when the same column is re-clicked).
|
||||
type CellGridProps struct {
|
||||
Columns []CellGridColumn
|
||||
Rows []map[string]any
|
||||
IDField string
|
||||
OnCellChange func(rowID any, field string, value any)
|
||||
SortKey string
|
||||
SetSortKey func(key string)
|
||||
SortDesc bool
|
||||
SetSortDesc func(desc bool)
|
||||
ConflictFields []string
|
||||
Dense bool
|
||||
}
|
||||
|
||||
// SortableHeaderProps configures a clickable, sort-indicating header cell.
|
||||
type SortableHeaderProps struct {
|
||||
Label string
|
||||
SortKey string
|
||||
Width string
|
||||
MinWidth string
|
||||
Current string // currently active sort key ("" == none)
|
||||
Desc bool
|
||||
OnSort func(key string)
|
||||
}
|
||||
|
||||
// SortableHeader renders a <th> that shows a caret when it is the active sort
|
||||
// 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"
|
||||
if sz := cellGridColumnSize(p.Width, p.MinWidth); sz != "" {
|
||||
cls += " " + sz
|
||||
}
|
||||
|
||||
inner := []vdom.Mod{
|
||||
vdom.Attr("class", "flex items-center gap-0.5 min-w-0"),
|
||||
vdom.El("span", vdom.Attr("class", "truncate min-w-0 flex-1"), vdom.Text(p.Label)),
|
||||
}
|
||||
if isActive {
|
||||
icon := "caret-up"
|
||||
if p.Desc {
|
||||
icon = "caret-down"
|
||||
}
|
||||
inner = append(inner, vdom.El("span", vdom.Attr("class", "shrink-0"), Icon(icon, 10, "")))
|
||||
}
|
||||
|
||||
mods := []vdom.Mod{vdom.Attr("class", cls)}
|
||||
if p.OnSort != nil {
|
||||
key := p.SortKey
|
||||
mods = append(mods, vdom.On(vdom.EVENT_CLICK, func() { p.OnSort(key) }))
|
||||
}
|
||||
mods = append(mods, vdom.El("div", inner...))
|
||||
return vdom.El("th", mods...)
|
||||
}
|
||||
|
||||
func cellGridConflictSets(p CellGridProps) map[string]map[string]bool {
|
||||
result := map[string]map[string]bool{}
|
||||
if len(p.ConflictFields) == 0 {
|
||||
return result
|
||||
}
|
||||
for _, field := range p.ConflictFields {
|
||||
counts := map[string]int{}
|
||||
for _, row := range p.Rows {
|
||||
v := row[field]
|
||||
if v == nil || v == "" {
|
||||
continue
|
||||
}
|
||||
counts[cellGridStr(v)]++
|
||||
}
|
||||
set := map[string]bool{}
|
||||
for v, c := range counts {
|
||||
if c > 1 {
|
||||
set[v] = true
|
||||
}
|
||||
}
|
||||
result[field] = set
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func cellGridSortedRows(p CellGridProps) []map[string]any {
|
||||
rows := make([]map[string]any, len(p.Rows))
|
||||
copy(rows, p.Rows)
|
||||
sk := p.SortKey
|
||||
if sk == "" {
|
||||
return rows
|
||||
}
|
||||
|
||||
var col *CellGridColumn
|
||||
for i := range p.Columns {
|
||||
if p.Columns[i].SortKey == sk {
|
||||
col = &p.Columns[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
st := "string"
|
||||
getVal := func(r map[string]any) any { return r[sk] }
|
||||
if col != nil {
|
||||
if col.SortType != "" {
|
||||
st = col.SortType
|
||||
}
|
||||
if col.SortValue != nil {
|
||||
getVal = col.SortValue
|
||||
}
|
||||
}
|
||||
|
||||
// Sort ascending stably, then reverse for desc (matches the TSX, which
|
||||
// reverses after a stable ascending sort rather than flipping ties).
|
||||
sort.SliceStable(rows, func(i, j int) bool {
|
||||
a := map[string]any{"v": getVal(rows[i])}
|
||||
b := map[string]any{"v": getVal(rows[j])}
|
||||
return CompareRowsGeneric(a, b, "v", st) < 0
|
||||
})
|
||||
if p.SortDesc {
|
||||
for i, j := 0, len(rows)-1; i < j; i, j = i+1, j-1 {
|
||||
rows[i], rows[j] = rows[j], rows[i]
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// CellGridDirty reports whether any editable field differs from initialRows (the
|
||||
// pure-logic half of the dropped CellGridApi.dirty()). A length mismatch or a nil
|
||||
// baseline counts as dirty.
|
||||
func CellGridDirty(columns []CellGridColumn, rows, initialRows []map[string]any) bool {
|
||||
if initialRows == nil || len(rows) != len(initialRows) {
|
||||
return true
|
||||
}
|
||||
var fields []string
|
||||
for _, c := range columns {
|
||||
if c.Editable {
|
||||
fields = append(fields, c.Key)
|
||||
}
|
||||
}
|
||||
for i := range rows {
|
||||
for _, f := range fields {
|
||||
if cellGridStr(rows[i][f]) != cellGridStr(initialRows[i][f]) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// CellGrid renders the sortable/editable grid. See the file NOTE for the
|
||||
// browser-only behaviors that are intentionally omitted.
|
||||
func CellGrid(p CellGridProps) *vdom.VNode {
|
||||
idf := p.IDField
|
||||
if idf == "" {
|
||||
idf = "id"
|
||||
}
|
||||
rowHCls := "h-8"
|
||||
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)]"
|
||||
|
||||
conflicts := cellGridConflictSets(p)
|
||||
isConflict := func(field string, value any) bool {
|
||||
if value == nil || value == "" {
|
||||
return false
|
||||
}
|
||||
set := conflicts[field]
|
||||
return set != nil && set[cellGridStr(value)]
|
||||
}
|
||||
fieldInConflictList := func(key string) bool {
|
||||
for _, f := range p.ConflictFields {
|
||||
if f == key {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
handleSort := func(key string) {
|
||||
if p.SortKey == key {
|
||||
if p.SetSortDesc != nil {
|
||||
p.SetSortDesc(!p.SortDesc)
|
||||
}
|
||||
return
|
||||
}
|
||||
if p.SetSortKey != nil {
|
||||
p.SetSortKey(key)
|
||||
}
|
||||
if p.SetSortDesc != nil {
|
||||
p.SetSortDesc(false)
|
||||
}
|
||||
}
|
||||
|
||||
renderHeader := func(col CellGridColumn) *vdom.VNode {
|
||||
if col.SortKey != "" {
|
||||
return SortableHeader(SortableHeaderProps{
|
||||
Label: col.Label,
|
||||
SortKey: col.SortKey,
|
||||
Width: col.Width,
|
||||
MinWidth: col.MinWidth,
|
||||
Current: p.SortKey,
|
||||
Desc: p.SortDesc,
|
||||
OnSort: handleSort,
|
||||
})
|
||||
}
|
||||
cls := GridHeaderCls
|
||||
if col.HeaderClass != "" {
|
||||
cls += " " + col.HeaderClass
|
||||
}
|
||||
if sz := cellGridColumnSize(col.Width, col.MinWidth); sz != "" {
|
||||
cls += " " + sz
|
||||
}
|
||||
return vdom.El("th", vdom.Attr("class", cls), vdom.Text(col.Label))
|
||||
}
|
||||
|
||||
renderCell := func(row map[string]any, col CellGridColumn) *vdom.VNode {
|
||||
rowID := row[idf]
|
||||
sizeCls := ""
|
||||
if sz := cellGridColumnSize(col.Width, col.MinWidth); sz != "" {
|
||||
sizeCls = " " + sz
|
||||
}
|
||||
cellClass := func() string {
|
||||
if col.CellClassFn != nil {
|
||||
return col.CellClassFn(row) + sizeCls
|
||||
}
|
||||
base := col.CellClass
|
||||
if base == "" {
|
||||
base = readonlyTdCls
|
||||
}
|
||||
return base + sizeCls
|
||||
}
|
||||
|
||||
// Custom-rendered, non-editable cell.
|
||||
if col.Render != nil && !col.Editable {
|
||||
mods := []vdom.Mod{vdom.Attr("class", cellClass())}
|
||||
if col.OnClick != nil {
|
||||
mods = append(mods, vdom.On(vdom.EVENT_CLICK, func() { col.OnClick(row) }))
|
||||
}
|
||||
mods = append(mods, col.Render(row))
|
||||
return vdom.El("td", mods...)
|
||||
}
|
||||
|
||||
// Read-only cell.
|
||||
if col.ReadOnly {
|
||||
mods := []vdom.Mod{vdom.Attr("class", cellClass())}
|
||||
if col.OnClick != nil {
|
||||
mods = append(mods, vdom.On(vdom.EVENT_CLICK, func() { col.OnClick(row) }))
|
||||
}
|
||||
if col.Render != nil {
|
||||
mods = append(mods, col.Render(row))
|
||||
} else {
|
||||
mods = append(mods, vdom.Text(cellGridStr(row[col.Key])))
|
||||
}
|
||||
return vdom.El("td", mods...)
|
||||
}
|
||||
|
||||
// Editable cell.
|
||||
inList := fieldInConflictList(col.Key)
|
||||
hasConflict := isConflict(col.Key, row[col.Key])
|
||||
tdCls := editableTdCls + sizeCls
|
||||
if inList {
|
||||
tdCls += " relative"
|
||||
if hasConflict {
|
||||
tdCls += " bg-amber-100"
|
||||
}
|
||||
}
|
||||
im := col.InputMode
|
||||
if im == "" {
|
||||
im = "text"
|
||||
}
|
||||
input := vdom.El("input",
|
||||
vdom.Attr("class", inputCls),
|
||||
vdom.Attr("type", "text"),
|
||||
vdom.Attr("inputmode", im),
|
||||
vdom.Attr("placeholder", col.Placeholder),
|
||||
vdom.Prop("value", cellGridStr(row[col.Key])),
|
||||
vdom.OnEvent(vdom.EVENT_INPUT, func(e vdom.Event) {
|
||||
var v any = e.Value()
|
||||
if col.Parse != nil {
|
||||
v = col.Parse(e.Value())
|
||||
}
|
||||
if p.OnCellChange != nil {
|
||||
p.OnCellChange(rowID, col.Key, v)
|
||||
}
|
||||
}),
|
||||
)
|
||||
mods := []vdom.Mod{vdom.Attr("class", tdCls), input}
|
||||
if inList && hasConflict {
|
||||
mods = append(mods, vdom.El("span",
|
||||
vdom.Attr("class", "pointer-events-none absolute right-0.5 top-1/2 -translate-y-1/2 text-amber-600"),
|
||||
vdom.Attr("title", "Duplicate value"),
|
||||
Icon("triangle-exclamation", 12, ""),
|
||||
))
|
||||
}
|
||||
return vdom.El("td", mods...)
|
||||
}
|
||||
|
||||
var headerCells []*vdom.VNode
|
||||
for _, col := range p.Columns {
|
||||
headerCells = append(headerCells, renderHeader(col))
|
||||
}
|
||||
thead := vdom.El("thead", vdom.El("tr", kids(nil, headerCells)...))
|
||||
|
||||
var bodyRows []*vdom.VNode
|
||||
for _, row := range cellGridSortedRows(p) {
|
||||
var cells []*vdom.VNode
|
||||
for _, col := range p.Columns {
|
||||
cells = append(cells, renderCell(row, col))
|
||||
}
|
||||
bodyRows = append(bodyRows, vdom.El("tr",
|
||||
kids([]vdom.Mod{vdom.Attr("class", "odd:bg-white even:bg-neutral-100")}, cells)...))
|
||||
}
|
||||
tbody := vdom.El("tbody", kids(nil, bodyRows)...)
|
||||
|
||||
tableCls := "min-w-full w-max border-collapse text-sm"
|
||||
if p.Dense {
|
||||
tableCls = "min-w-full w-max border-collapse text-xs"
|
||||
}
|
||||
return vdom.El("div",
|
||||
vdom.Attr("class", "relative max-w-full overflow-x-auto border border-neutral-300 rounded-default bg-white tabular-nums"),
|
||||
vdom.El("table", vdom.Attr("class", tableCls), thead, tbody),
|
||||
)
|
||||
}
|
||||
44
go/webui/chart.go
Normal file
44
go/webui/chart.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package webui
|
||||
|
||||
import "kjol/vdom"
|
||||
|
||||
// Port of web/kit/Chart.tsx (default export ReactiveChart).
|
||||
//
|
||||
// NOTE: The TSX wraps chart.js — it imperatively creates a `new Chart(ctx, cfg)`
|
||||
// against a <canvas> 2D context inside onMount, and pushes new data/options via
|
||||
// createEffect. None of that (canvas 2D drawing, a JS charting lib, mount/cleanup
|
||||
// lifecycles) exists in the neutral gowasm runtime, so actual chart DRAWING is out
|
||||
// of scope here. This port renders the faithful container structure + Tailwind and
|
||||
// an empty <canvas>, keeping a props shape for API parity. The example app instead
|
||||
// draws charts server-side as go-chart SVG.
|
||||
|
||||
// Chart type identifiers (chart.js `type`), kept for API parity with the TSX
|
||||
// ChartType union. Unused by this SVG-less container.
|
||||
const (
|
||||
ChartTypeLine = "line"
|
||||
ChartTypeBar = "bar"
|
||||
ChartTypeRadar = "radar"
|
||||
ChartTypeDoughnut = "doughnut"
|
||||
ChartTypePolarArea = "polarArea"
|
||||
ChartTypeBubble = "bubble"
|
||||
ChartTypePie = "pie"
|
||||
ChartTypeScatter = "scatter"
|
||||
)
|
||||
|
||||
// ReactiveChartProps mirrors the TSX props. Data and Options are accepted for API
|
||||
// parity but are not rendered (no JS chart lib in the neutral runtime; see NOTE).
|
||||
type ReactiveChartProps struct {
|
||||
Type string
|
||||
Data any
|
||||
Options any
|
||||
Class string
|
||||
}
|
||||
|
||||
// ReactiveChart renders the chart container (h-full + user class) wrapping an
|
||||
// empty <canvas>. Drawing is out of scope — see the file NOTE.
|
||||
func ReactiveChart(p ReactiveChartProps) *vdom.VNode {
|
||||
return vdom.El("div",
|
||||
vdom.Attr("class", cx("h-full", p.Class)),
|
||||
vdom.El("canvas"),
|
||||
)
|
||||
}
|
||||
138
go/webui/crmtabs.go
Normal file
138
go/webui/crmtabs.go
Normal file
@@ -0,0 +1,138 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"kjol/vdom"
|
||||
)
|
||||
|
||||
// Port of web/kit/CrmTabs.tsx.
|
||||
//
|
||||
// CrmTabGroup / CrmSubTabGroup are behaviourally-identical siblings of TabGroup
|
||||
// with a different look: CrmTabGroup is boxed tabs with a sky-blue top accent;
|
||||
// CrmSubTabGroup is a segmented control. Content sits flat beneath the tab row.
|
||||
//
|
||||
// NOTE: As in Tabs.tsx, the TSX localStorage persistence (storageKey) and
|
||||
// cross-component "storage"-event syncing are browser-only and dropped;
|
||||
// selection collapses to a plain ActiveIndex value + OnTabChange callback (the
|
||||
// caller owns state). The TSX defaultIndex prop collapses into ActiveIndex.
|
||||
|
||||
// CrmTabItem is one CRM tab: title, optional numeric badge (shown when > 0), and
|
||||
// inline panel content.
|
||||
type CrmTabItem struct {
|
||||
Title string
|
||||
Badge int
|
||||
Content *vdom.VNode
|
||||
}
|
||||
|
||||
// CrmTabGroupProps configures CrmTabGroup / CrmSubTabGroup. ActiveIndex is the
|
||||
// selected tab; clicking a tab calls OnTabChange with its index.
|
||||
type CrmTabGroupProps struct {
|
||||
Items []CrmTabItem
|
||||
ActiveIndex int
|
||||
OnTabChange func(int)
|
||||
}
|
||||
|
||||
// crmTabsPanels renders one wrapper div per item; only the active one is shown.
|
||||
func crmTabsPanels(p CrmTabGroupProps) []*vdom.VNode {
|
||||
out := make([]*vdom.VNode, 0, len(p.Items))
|
||||
for i, item := range p.Items {
|
||||
cls := "hidden"
|
||||
if i == p.ActiveIndex {
|
||||
cls = ""
|
||||
}
|
||||
out = append(out, vdom.El("div", vdom.Attr("class", cls), item.Content))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// --- 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 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
|
||||
// beneath the row (no body panel), with a trailing filler extending the baseline.
|
||||
func CrmTabGroup(p CrmTabGroupProps) *vdom.VNode {
|
||||
row := []vdom.Mod{vdom.Attr("class", crmTabRow)}
|
||||
for i, item := range p.Items {
|
||||
idx := i
|
||||
state := crmTabInactive
|
||||
if i == p.ActiveIndex {
|
||||
state = crmTabActive
|
||||
}
|
||||
btn := []vdom.Mod{
|
||||
vdom.Attr("type", "button"),
|
||||
vdom.Attr("class", cx(crmTabBase, state)),
|
||||
vdom.On(vdom.EVENT_CLICK, func() {
|
||||
if p.OnTabChange != nil {
|
||||
p.OnTabChange(idx)
|
||||
}
|
||||
}),
|
||||
vdom.Text(item.Title),
|
||||
}
|
||||
if item.Badge > 0 {
|
||||
btn = append(btn, vdom.El("span", vdom.Attr("class", crmTabBadge), vdom.Text(strconv.Itoa(item.Badge))))
|
||||
}
|
||||
row = append(row, vdom.El("button", btn...))
|
||||
}
|
||||
row = append(row, vdom.El("div", vdom.Attr("class", "flex-1 border-b border-neutral-300")))
|
||||
|
||||
return vdom.El("div",
|
||||
vdom.Attr("class", "w-full"),
|
||||
vdom.El("div", row...),
|
||||
vdom.El("div", kids(nil, crmTabsPanels(p))...),
|
||||
)
|
||||
}
|
||||
|
||||
// --- 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 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 crmSubTabActive = "bg-neutral-500 text-white"
|
||||
const crmSubTabInactive = "bg-white text-neutral-600 hover:bg-neutral-100 hover:text-neutral-900"
|
||||
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
|
||||
// segments) with a full-width baseline separating the bar from the content.
|
||||
func CrmSubTabGroup(p CrmTabGroupProps) *vdom.VNode {
|
||||
group := []vdom.Mod{vdom.Attr("class", crmSubTabGroup)}
|
||||
for i, item := range p.Items {
|
||||
idx := i
|
||||
divider := ""
|
||||
if i > 0 {
|
||||
divider = crmSubTabDivider
|
||||
}
|
||||
state := crmSubTabInactive
|
||||
if i == p.ActiveIndex {
|
||||
state = crmSubTabActive
|
||||
}
|
||||
btn := []vdom.Mod{
|
||||
vdom.Attr("type", "button"),
|
||||
vdom.Attr("class", cx(crmSubTabBase, divider, state)),
|
||||
vdom.On(vdom.EVENT_CLICK, func() {
|
||||
if p.OnTabChange != nil {
|
||||
p.OnTabChange(idx)
|
||||
}
|
||||
}),
|
||||
vdom.Text(item.Title),
|
||||
}
|
||||
if item.Badge > 0 {
|
||||
btn = append(btn, vdom.El("span", vdom.Attr("class", crmSubTabBadge), vdom.Text(strconv.Itoa(item.Badge))))
|
||||
}
|
||||
group = append(group, vdom.El("button", btn...))
|
||||
}
|
||||
return vdom.El("div",
|
||||
vdom.Attr("class", "w-full pt-3"),
|
||||
vdom.El("div",
|
||||
vdom.Attr("class", crmSubTabWrap),
|
||||
vdom.El("div", group...),
|
||||
),
|
||||
vdom.El("div", kids([]vdom.Mod{vdom.Attr("class", "pt-3")}, crmTabsPanels(p))...),
|
||||
)
|
||||
}
|
||||
307
go/webui/datepicker.go
Normal file
307
go/webui/datepicker.go
Normal file
@@ -0,0 +1,307 @@
|
||||
// Port of web/kit/DatePicker.tsx. Date math uses the stdlib time package.
|
||||
package webui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"kjol/vdom"
|
||||
)
|
||||
|
||||
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 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"
|
||||
|
||||
// NOTE: the TSX renders the dropdown into a <Portal> with fixed coordinates from
|
||||
// getBoundingClientRect (tracked each frame). Portals, refs and element
|
||||
// measurement have no equivalent here, so the dropdown is rendered inline and
|
||||
// positioned with static Tailwind (absolute, below the field).
|
||||
const datePickerDropdownPos = "absolute left-0 top-full mt-1 z-[200]"
|
||||
|
||||
// datePickerInputBase mirrors Forms.tsx INPUT_BASE (light, no error/success).
|
||||
const datePickerInputBase = "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"
|
||||
|
||||
// datepickerInputCls reproduces Forms.tsx _inputCls for the light, no-state case.
|
||||
func datepickerInputCls(small bool, extra string) string {
|
||||
h, pad := "h-[38px]", "p-2"
|
||||
if small {
|
||||
h, pad = "h-[30px]", "p-1"
|
||||
}
|
||||
return cx(datePickerInputBase, h, pad, "border-neutral-300 focus:outline-sky-500", extra)
|
||||
}
|
||||
|
||||
// datepickerParseInput parses typed/pasted text into a "YYYY-MM-DD" key, or "".
|
||||
//
|
||||
// NOTE: the TSX first tries YYYY-MM-DD, then falls back to the very lenient
|
||||
// `new Date(text)`. Go has no equivalent, so a fixed set of common layouts is
|
||||
// tried instead.
|
||||
func datepickerParseInput(text string) string {
|
||||
s := strings.TrimSpace(text)
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
for _, layout := range []string{"2006-01-02", "1/2/2006", "01/02/2006", "2006/01/02", "January 2, 2006", "Jan 2, 2006"} {
|
||||
if t, err := time.Parse(layout, s); err == nil {
|
||||
return t.Format("2006-01-02")
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// datepickerFormatDisplay renders a "YYYY-MM-DD" key for display.
|
||||
//
|
||||
// NOTE: the TSX uses Date.toLocaleDateString() (locale-dependent). Go has no
|
||||
// locale formatter, so this approximates the common en-US "M/D/YYYY" form.
|
||||
func datepickerFormatDisplay(iso string) string {
|
||||
t, ok := calParseKey(iso)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%d/%d/%d", int(t.Month()), t.Day(), t.Year())
|
||||
}
|
||||
|
||||
// datepickerInput renders the FormInput approximation: a .ui-form wrapper around
|
||||
// a styled <input>. onChange commits the parsed value on the native change event.
|
||||
//
|
||||
// NOTE: the TSX FormInput tracks an editing/draft state machine across
|
||||
// focus/input/blur. Without persistent local signals that collapses to a single
|
||||
// onchange commit; the input shows the formatted external value otherwise.
|
||||
func datepickerInput(value, placeholder string, small bool, extra string, onChange func(string)) *vdom.VNode {
|
||||
mods := []vdom.Mod{
|
||||
vdom.Attr("type", "text"),
|
||||
vdom.Attr("class", datepickerInputCls(small, extra)),
|
||||
vdom.Attr("placeholder", placeholder),
|
||||
vdom.Prop("value", value),
|
||||
}
|
||||
if onChange != nil {
|
||||
mods = append(mods, vdom.OnEvent(vdom.EVENT_CHANGE, func(e vdom.Event) {
|
||||
onChange(datepickerParseInput(e.Value()))
|
||||
}))
|
||||
}
|
||||
return vdom.El("div", vdom.Attr("class", "ui-form"), vdom.El("input", mods...))
|
||||
}
|
||||
|
||||
func datepickerIconButton(onClick func()) *vdom.VNode {
|
||||
return vdom.El("button",
|
||||
vdom.Attr("type", "button"),
|
||||
vdom.Attr("class", datePickerIconBtn),
|
||||
vdom.Attr("aria-label", "Open calendar"),
|
||||
vdom.On(vdom.EVENT_CLICK, onClick),
|
||||
Icon("calendar", 16, "block leading-none"),
|
||||
)
|
||||
}
|
||||
|
||||
// DatePickerProps configures DatePicker and DateOfBirthPicker. It is controlled:
|
||||
// Value is the "YYYY-MM-DD" selection, Open is the dropdown state, ViewMonth is
|
||||
// the calendar's visible month — each paired with a callback.
|
||||
//
|
||||
// NOTE: the TSX owns open/editing/localValue/dropdownPos in internal signals and
|
||||
// closes the dropdown via a document click listener. Outside-click handling,
|
||||
// stopPropagation and computed positioning aren't representable in the neutral
|
||||
// runtime, so open/selection/month are lifted to props + callbacks.
|
||||
type DatePickerProps struct {
|
||||
Value string // selected date "YYYY-MM-DD"
|
||||
OnChange func(value string) // new selection (or "" when cleared)
|
||||
Placeholder string // input placeholder
|
||||
Small bool // compact input height
|
||||
Clearable bool // show a clear (x) button when a value is set
|
||||
Open bool // dropdown open state (controlled)
|
||||
OnToggle func(open bool) // request to open/close the dropdown
|
||||
ViewMonth time.Time // visible month of the dropdown calendar
|
||||
OnNavigate func(month time.Time) // prev/next month requested in the dropdown
|
||||
}
|
||||
|
||||
// DatePicker renders a text field with a calendar dropdown (picker layout).
|
||||
func DatePicker(p DatePickerProps) *vdom.VNode {
|
||||
hasValue := p.Value != ""
|
||||
|
||||
extra := "w-full pr-9"
|
||||
if p.Clearable && hasValue {
|
||||
extra = "w-full pr-14"
|
||||
}
|
||||
|
||||
fieldMods := []vdom.Mod{
|
||||
vdom.Attr("class", datePickerField),
|
||||
vdom.On(vdom.EVENT_CLICK, func() {
|
||||
if p.OnToggle != nil {
|
||||
p.OnToggle(true)
|
||||
}
|
||||
}),
|
||||
datepickerInput(datepickerFormatDisplay(p.Value), pick(p.Placeholder, "Select date"), p.Small, extra, p.OnChange),
|
||||
}
|
||||
if p.Clearable && hasValue {
|
||||
fieldMods = append(fieldMods, vdom.El("button",
|
||||
vdom.Attr("type", "button"),
|
||||
vdom.Attr("class", datePickerClearBtn),
|
||||
vdom.Attr("aria-label", "Clear date"),
|
||||
vdom.On(vdom.EVENT_CLICK, func() {
|
||||
if p.OnChange != nil {
|
||||
p.OnChange("")
|
||||
}
|
||||
if p.OnToggle != nil {
|
||||
p.OnToggle(false)
|
||||
}
|
||||
}),
|
||||
Icon("xmark", 14, "block leading-none"),
|
||||
))
|
||||
}
|
||||
fieldMods = append(fieldMods, datepickerIconButton(func() {
|
||||
if p.OnToggle != nil {
|
||||
p.OnToggle(!p.Open)
|
||||
}
|
||||
}))
|
||||
|
||||
wrapMods := []vdom.Mod{
|
||||
vdom.Attr("class", datePickerWrap),
|
||||
vdom.El("div", fieldMods...),
|
||||
}
|
||||
|
||||
if p.Open {
|
||||
cal := Calendar(CalendarProps{
|
||||
Selected: p.Value,
|
||||
ViewMonth: p.ViewMonth,
|
||||
Variant: CalendarVariantPicker,
|
||||
OnSelect: func(key string) {
|
||||
if p.OnChange != nil {
|
||||
p.OnChange(key)
|
||||
}
|
||||
if p.OnToggle != nil {
|
||||
p.OnToggle(false)
|
||||
}
|
||||
},
|
||||
OnNavigate: p.OnNavigate,
|
||||
})
|
||||
wrapMods = append(wrapMods, vdom.El("div",
|
||||
vdom.Attr("class", cx(datePickerDropdown, datePickerDropdownPos)),
|
||||
cal,
|
||||
))
|
||||
}
|
||||
|
||||
return vdom.El("div", wrapMods...)
|
||||
}
|
||||
|
||||
// datepickerDOBCalendar renders the date-of-birth calendar: the picker grid with
|
||||
// month/year <select> dropdowns in place of the static month label. Navigation
|
||||
// (prev/next and both selects) is reported through onNavigate as a first-of-month.
|
||||
func datepickerDOBCalendar(selKey string, view time.Time, onSelect func(string), onNavigate func(time.Time)) *vdom.VNode {
|
||||
now := time.Now()
|
||||
sel, hasSel := calParseKey(selKey)
|
||||
|
||||
if view.IsZero() {
|
||||
if hasSel {
|
||||
view = sel
|
||||
} else {
|
||||
view = now
|
||||
}
|
||||
}
|
||||
year := view.Year()
|
||||
month := view.Month()
|
||||
|
||||
navTo := func(m time.Time) {
|
||||
if onNavigate != nil {
|
||||
onNavigate(m)
|
||||
}
|
||||
}
|
||||
|
||||
// Month select (option values are 0-indexed, matching the TSX).
|
||||
monthMods := []vdom.Mod{
|
||||
vdom.Attr("class", calSelect),
|
||||
vdom.Prop("value", strconv.Itoa(int(month)-1)),
|
||||
vdom.OnEvent(vdom.EVENT_CHANGE, func(e vdom.Event) {
|
||||
if m, err := strconv.Atoi(e.Value()); err == nil {
|
||||
navTo(time.Date(year, time.Month(m+1), 1, 0, 0, 0, 0, time.UTC))
|
||||
}
|
||||
}),
|
||||
}
|
||||
for i, mn := range calMonthsShort {
|
||||
monthMods = append(monthMods, vdom.El("option", vdom.Attr("value", strconv.Itoa(i)), vdom.Text(mn)))
|
||||
}
|
||||
|
||||
// Year select: current year down to current-119 (120 years), like the TSX.
|
||||
yearMods := []vdom.Mod{
|
||||
vdom.Attr("class", calSelect),
|
||||
vdom.Prop("value", strconv.Itoa(year)),
|
||||
vdom.OnEvent(vdom.EVENT_CHANGE, func(e vdom.Event) {
|
||||
if y, err := strconv.Atoi(e.Value()); err == nil {
|
||||
navTo(time.Date(y, month, 1, 0, 0, 0, 0, time.UTC))
|
||||
}
|
||||
}),
|
||||
}
|
||||
for y := now.Year(); y > now.Year()-120; y-- {
|
||||
ys := strconv.Itoa(y)
|
||||
yearMods = append(yearMods, vdom.El("option", vdom.Attr("value", ys), vdom.Text(ys)))
|
||||
}
|
||||
|
||||
header := vdom.El("div", vdom.Attr("class", calHeaderPicker),
|
||||
vdom.El("button",
|
||||
vdom.Attr("type", "button"),
|
||||
vdom.Attr("class", calNavBtn),
|
||||
vdom.On(vdom.EVENT_CLICK, func() {
|
||||
navTo(time.Date(year, month-1, 1, 0, 0, 0, 0, time.UTC))
|
||||
}),
|
||||
Icon("chevron-left", 16, ""),
|
||||
),
|
||||
vdom.El("select", monthMods...),
|
||||
vdom.El("select", yearMods...),
|
||||
vdom.El("button",
|
||||
vdom.Attr("type", "button"),
|
||||
vdom.Attr("class", calNavBtn),
|
||||
vdom.On(vdom.EVENT_CLICK, func() {
|
||||
navTo(time.Date(year, month+1, 1, 0, 0, 0, 0, time.UTC))
|
||||
}),
|
||||
Icon("chevron-right", 16, ""),
|
||||
),
|
||||
)
|
||||
|
||||
return vdom.El("div",
|
||||
vdom.Attr("class", calPickerRoot),
|
||||
header,
|
||||
calWeekdaysRow(calWeekdaysPicker, calWeekdayPicker),
|
||||
calPickerDaysGrid(view, sel, hasSel, now, onSelect, nil),
|
||||
)
|
||||
}
|
||||
|
||||
// DateOfBirthPicker is DatePicker with a month/year select calendar, suited to
|
||||
// picking far-past dates. It reuses DatePickerProps (Clearable is unused).
|
||||
func DateOfBirthPicker(p DatePickerProps) *vdom.VNode {
|
||||
fieldMods := []vdom.Mod{
|
||||
vdom.Attr("class", datePickerField),
|
||||
vdom.On(vdom.EVENT_CLICK, func() {
|
||||
if p.OnToggle != nil {
|
||||
p.OnToggle(true)
|
||||
}
|
||||
}),
|
||||
datepickerInput(datepickerFormatDisplay(p.Value), pick(p.Placeholder, "Select date of birth"), p.Small, "w-full pr-9", p.OnChange),
|
||||
datepickerIconButton(func() {
|
||||
if p.OnToggle != nil {
|
||||
p.OnToggle(!p.Open)
|
||||
}
|
||||
}),
|
||||
}
|
||||
|
||||
wrapMods := []vdom.Mod{
|
||||
vdom.Attr("class", datePickerWrap),
|
||||
vdom.El("div", fieldMods...),
|
||||
}
|
||||
|
||||
if p.Open {
|
||||
cal := datepickerDOBCalendar(p.Value, p.ViewMonth, func(key string) {
|
||||
if p.OnChange != nil {
|
||||
p.OnChange(key)
|
||||
}
|
||||
if p.OnToggle != nil {
|
||||
p.OnToggle(false)
|
||||
}
|
||||
}, p.OnNavigate)
|
||||
wrapMods = append(wrapMods, vdom.El("div",
|
||||
vdom.Attr("class", cx(datePickerDropdown, datePickerDropdownPos)),
|
||||
cal,
|
||||
))
|
||||
}
|
||||
|
||||
return vdom.El("div", wrapMods...)
|
||||
}
|
||||
47
go/webui/envbadge.go
Normal file
47
go/webui/envbadge.go
Normal file
@@ -0,0 +1,47 @@
|
||||
// Port of web/kit/EnvBadge.tsx.
|
||||
|
||||
package webui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"kjol/vdom"
|
||||
)
|
||||
|
||||
// IsNonProdEnv reports whether env is a non-empty, non-production deployment
|
||||
// environment (development, staging, …). Mirrors env.ts's isNonProdEnv. In the
|
||||
// TSX the environment is a build-time constant (ENV_TYPE); here it collapses to
|
||||
// a plain argument the caller supplies.
|
||||
func IsNonProdEnv(env string) bool {
|
||||
return env != "" && env != "production"
|
||||
}
|
||||
|
||||
const envBadgeBase = "pointer-events-none select-none absolute top-0 -right-2 z-10 " +
|
||||
"rounded px-1 py-px text-[0.5rem] font-bold uppercase leading-none tracking-wider shadow-sm"
|
||||
|
||||
// EnvBadge renders a small environment badge pinned to the corner of the app
|
||||
// logo. It returns nil in production (and for an empty env), so it renders
|
||||
// nothing there — drop it inside a position:relative wrapper around the logo so
|
||||
// it anchors to the logo's top-right corner.
|
||||
//
|
||||
// NOTE: the TSX also tags <html data-env=…> as a module side effect
|
||||
// (markEnvOnRoot). That is a browser-only document mutation with no
|
||||
// neutral-runtime equivalent and is dropped here; do it in app bootstrap if the
|
||||
// env-specific styling hook is needed.
|
||||
func EnvBadge(env string) *vdom.VNode {
|
||||
if !IsNonProdEnv(env) {
|
||||
return nil
|
||||
}
|
||||
|
||||
var label, tone string
|
||||
switch env {
|
||||
case "development":
|
||||
label, tone = "DEV", "bg-orange-500 text-white"
|
||||
case "staging":
|
||||
label, tone = "STAGING", "bg-yellow-400 text-gray-900"
|
||||
default:
|
||||
label, tone = strings.ToUpper(env), "bg-neutral-700 text-white"
|
||||
}
|
||||
|
||||
return vdom.El("span", vdom.Attr("class", cx(envBadgeBase, tone)), vdom.Text(label))
|
||||
}
|
||||
189
go/webui/floating.go
Normal file
189
go/webui/floating.go
Normal file
@@ -0,0 +1,189 @@
|
||||
package webui
|
||||
|
||||
import "kjol/vdom"
|
||||
|
||||
// Port of web/kit/Floating.tsx.
|
||||
//
|
||||
// The TSX is a floating-ui-style popover kit built on Solid context, refs,
|
||||
// getBoundingClientRect, requestAnimationFrame, window scroll/resize listeners,
|
||||
// document-level mousedown/keydown handlers, a Portal to document.body, and a
|
||||
// global single-open FloatingManager. None of that has an equivalent in the
|
||||
// neutral vdom runtime, so this port keeps the API shape (Root / Trigger /
|
||||
// Content) plus the Tailwind, and models open state as a plain value + callback.
|
||||
//
|
||||
// NOTE: All computed positioning (calculatePosition, flip, shift, offset px,
|
||||
// getBoundingClientRect, the position() signal, window scroll/resize reflow) is
|
||||
// dropped. FloatingContent is approximated with a statically-positioned
|
||||
// `absolute` element anchored to FloatingRoot's `relative` container, placed via
|
||||
// Tailwind utilities chosen from the Placement value. The original rendered the
|
||||
// content through a Portal with `position: fixed`; here it stays in-flow.
|
||||
// NOTE: The global single-open FloatingManager, the open-order stack
|
||||
// (openFloatings), outside-click dismissal, Escape-to-close, and hover-open /
|
||||
// hover-close timers are dropped — callers own open state and decide when to
|
||||
// toggle it. useFloatingContext / FloatingContextValue and the useFloatingHover
|
||||
// hook are dropped (no Solid context; nothing to share through).
|
||||
|
||||
// Placement values (subset of CSS anchor placements) understood by
|
||||
// FloatingContent's static positioning approximation.
|
||||
const (
|
||||
PlacementTop = "top"
|
||||
PlacementTopStart = "top-start"
|
||||
PlacementTopEnd = "top-end"
|
||||
PlacementBottom = "bottom"
|
||||
PlacementBottomStart = "bottom-start"
|
||||
PlacementBottomEnd = "bottom-end"
|
||||
PlacementLeft = "left"
|
||||
PlacementLeftStart = "left-start"
|
||||
PlacementLeftEnd = "left-end"
|
||||
PlacementRight = "right"
|
||||
PlacementRightStart = "right-start"
|
||||
PlacementRightEnd = "right-end"
|
||||
)
|
||||
|
||||
// PositionOptions mirrors the TSX PositionOptions. Retained for API parity; in
|
||||
// this port only Placement influences rendering (see the file-level NOTE) — the
|
||||
// numeric/flip/shift fields are not consumed because there is no measurement.
|
||||
type PositionOptions struct {
|
||||
Placement string
|
||||
Offset int
|
||||
Flip bool
|
||||
Shift bool
|
||||
ShiftPadding int
|
||||
}
|
||||
|
||||
// floatingPlacementClass maps a Placement to Tailwind utilities that position an
|
||||
// `absolute` child relative to its `relative` FloatingRoot container. The ~4px
|
||||
// default offset is approximated with the mt-1/mb-1/ml-1/mr-1 gap classes.
|
||||
func floatingPlacementClass(placement string) string {
|
||||
switch placement {
|
||||
case PlacementTop:
|
||||
return "bottom-full left-1/2 -translate-x-1/2 mb-1"
|
||||
case PlacementTopStart:
|
||||
return "bottom-full left-0 mb-1"
|
||||
case PlacementTopEnd:
|
||||
return "bottom-full right-0 mb-1"
|
||||
case PlacementBottom:
|
||||
return "top-full left-1/2 -translate-x-1/2 mt-1"
|
||||
case PlacementBottomEnd:
|
||||
return "top-full right-0 mt-1"
|
||||
case PlacementLeft:
|
||||
return "right-full top-1/2 -translate-y-1/2 mr-1"
|
||||
case PlacementLeftStart:
|
||||
return "right-full top-0 mr-1"
|
||||
case PlacementLeftEnd:
|
||||
return "right-full bottom-0 mr-1"
|
||||
case PlacementRight:
|
||||
return "left-full top-1/2 -translate-y-1/2 ml-1"
|
||||
case PlacementRightStart:
|
||||
return "left-full top-0 ml-1"
|
||||
case PlacementRightEnd:
|
||||
return "left-full bottom-0 ml-1"
|
||||
default: // PlacementBottomStart and unknown values
|
||||
return "top-full left-0 mt-1"
|
||||
}
|
||||
}
|
||||
|
||||
// FloatingRootProps configures FloatingRoot. Open is the current open state
|
||||
// (caller-held); OnOpenChange is invoked by children that toggle it. The
|
||||
// numeric/flip/shift/standalone fields are retained for API parity but are not
|
||||
// used by the static-positioning approximation (see the file-level NOTE).
|
||||
type FloatingRootProps struct {
|
||||
Open bool
|
||||
OnOpenChange func(bool)
|
||||
Placement string
|
||||
Offset int
|
||||
Flip bool
|
||||
Shift bool
|
||||
ShiftPadding int
|
||||
Standalone bool
|
||||
Class string
|
||||
}
|
||||
|
||||
// FloatingRoot wraps a Trigger + Content pair. The TSX component rendered no DOM
|
||||
// node (only a context provider); this port emits a `relative inline-block`
|
||||
// container so the absolutely-positioned FloatingContent has an anchor.
|
||||
func FloatingRoot(p FloatingRootProps, children ...*vdom.VNode) *vdom.VNode {
|
||||
return vdom.El("div", kids([]vdom.Mod{
|
||||
vdom.Attr("class", cx("relative inline-block", p.Class)),
|
||||
}, children)...)
|
||||
}
|
||||
|
||||
// FloatingTriggerProps configures FloatingTrigger. Open feeds aria-expanded;
|
||||
// OnToggle fires on click. OpenOnHover/HoverDelay/HoverCloseDelay are retained
|
||||
// for API parity but are inert here (hover timers dropped).
|
||||
type FloatingTriggerProps struct {
|
||||
Open bool
|
||||
OnToggle func()
|
||||
OpenOnHover bool
|
||||
HoverDelay int
|
||||
HoverCloseDelay int
|
||||
Class string
|
||||
Title string
|
||||
}
|
||||
|
||||
// FloatingTrigger renders the <button> that toggles the floating content.
|
||||
//
|
||||
// NOTE: keyboard activation (Enter/Space to toggle, Escape to close) and
|
||||
// hover-open/hover-close behavior are dropped — the vdom Event exposes no key,
|
||||
// and there are no timers. Click toggling via OnToggle is preserved.
|
||||
func FloatingTrigger(p FloatingTriggerProps, children ...*vdom.VNode) *vdom.VNode {
|
||||
ariaExpanded := "false"
|
||||
if p.Open {
|
||||
ariaExpanded = "true"
|
||||
}
|
||||
mods := []vdom.Mod{
|
||||
vdom.Attr("type", "button"),
|
||||
vdom.Attr("class", p.Class),
|
||||
vdom.Attr("aria-expanded", ariaExpanded),
|
||||
vdom.Attr("aria-haspopup", "menu"),
|
||||
}
|
||||
if p.Title != "" {
|
||||
mods = append(mods, vdom.Attr("title", p.Title))
|
||||
}
|
||||
if p.OnToggle != nil {
|
||||
mods = append(mods, vdom.On(vdom.EVENT_CLICK, p.OnToggle))
|
||||
}
|
||||
return vdom.El("button", kids(mods, children)...)
|
||||
}
|
||||
|
||||
// FloatingContentProps configures FloatingContent. Open toggles visibility;
|
||||
// Placement picks the static position (see floatingPlacementClass). Style is an
|
||||
// optional extra inline-style passthrough. OnMouseEnter/OnMouseLeave are wired
|
||||
// (used by hover popovers to keep themselves open), though the close timer they
|
||||
// fed in the TSX is gone.
|
||||
type FloatingContentProps struct {
|
||||
Open bool
|
||||
Placement string
|
||||
Class string
|
||||
Style string
|
||||
OnMouseEnter func()
|
||||
OnMouseLeave func()
|
||||
}
|
||||
|
||||
// FloatingContent renders the popover panel (role="menu"). It is hidden via the
|
||||
// `hidden` utility while closed rather than unmounted.
|
||||
//
|
||||
// NOTE: rendered in-flow as an `absolute` element instead of portaled to
|
||||
// document.body with computed `position: fixed` coordinates; z-[110] preserves
|
||||
// the TSX's stacking intent (above a z-[100] modal container).
|
||||
func FloatingContent(p FloatingContentProps, children ...*vdom.VNode) *vdom.VNode {
|
||||
vis := "hidden"
|
||||
if p.Open {
|
||||
vis = "block"
|
||||
}
|
||||
mods := []vdom.Mod{
|
||||
vdom.Attr("role", "menu"),
|
||||
vdom.Attr("data-floating-content", "true"),
|
||||
vdom.Attr("class", cx("absolute z-[110]", floatingPlacementClass(p.Placement), vis, p.Class)),
|
||||
}
|
||||
if p.Style != "" {
|
||||
mods = append(mods, vdom.Attr("style", p.Style))
|
||||
}
|
||||
if p.OnMouseEnter != nil {
|
||||
mods = append(mods, vdom.On("mouseenter", p.OnMouseEnter))
|
||||
}
|
||||
if p.OnMouseLeave != nil {
|
||||
mods = append(mods, vdom.On("mouseleave", p.OnMouseLeave))
|
||||
}
|
||||
return vdom.El("div", kids(mods, children)...)
|
||||
}
|
||||
306
go/webui/formatters.go
Normal file
306
go/webui/formatters.go
Normal file
@@ -0,0 +1,306 @@
|
||||
// Port of web/kit/Formatters.ts.
|
||||
package webui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// formattersNonDigit matches every non-digit rune (the JS /\D/g).
|
||||
var formattersNonDigit = regexp.MustCompile(`\D`)
|
||||
|
||||
// formattersDigits strips every non-digit character (mirrors String(n).replace(/\D/g, "")).
|
||||
func formattersDigits(s string) string {
|
||||
return formattersNonDigit.ReplaceAllString(s, "")
|
||||
}
|
||||
|
||||
// formattersStringify mimics JavaScript's String(v) for the `string | number`
|
||||
// union accepted by several formatters. Go has no union type, so these take
|
||||
// `any` and stringify int / float / string inputs the way JS would.
|
||||
func formattersStringify(v any) string {
|
||||
switch n := v.(type) {
|
||||
case string:
|
||||
return n
|
||||
case int:
|
||||
return strconv.Itoa(n)
|
||||
case int8, int16, int32, int64:
|
||||
return fmt.Sprintf("%d", n)
|
||||
case uint, uint8, uint16, uint32, uint64:
|
||||
return fmt.Sprintf("%d", n)
|
||||
case float32:
|
||||
return strconv.FormatFloat(float64(n), 'f', -1, 32)
|
||||
case float64:
|
||||
return strconv.FormatFloat(n, 'f', -1, 64)
|
||||
default:
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
|
||||
// formattersPadStartZero left-pads s with '0' up to length (the JS padStart(n, "0")).
|
||||
func formattersPadStartZero(s string, length int) string {
|
||||
if len(s) >= length {
|
||||
return s
|
||||
}
|
||||
return strings.Repeat("0", length-len(s)) + s
|
||||
}
|
||||
|
||||
// formattersParseInt approximates JS parseInt(s, 10): optional sign then leading
|
||||
// decimal digits. JS would yield NaN for non-numeric input; here we return 0.
|
||||
func formattersParseInt(s string) int {
|
||||
s = strings.TrimSpace(s)
|
||||
i := 0
|
||||
neg := false
|
||||
if i < len(s) && (s[i] == '+' || s[i] == '-') {
|
||||
neg = s[i] == '-'
|
||||
i++
|
||||
}
|
||||
start := i
|
||||
for i < len(s) && s[i] >= '0' && s[i] <= '9' {
|
||||
i++
|
||||
}
|
||||
if start == i {
|
||||
return 0
|
||||
}
|
||||
n, err := strconv.Atoi(s[start:i])
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
if neg {
|
||||
return -n
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// FormatPhoneNumber formats a number as a US phone number: (XXX) XXX-XXXX.
|
||||
func FormatPhoneNumber(number any) string {
|
||||
digits := formattersPadStartZero(formattersDigits(formattersStringify(number)), 10)[:10]
|
||||
areaCode := digits[0:3]
|
||||
centralOfficeCode := digits[3:6]
|
||||
lineNumber := digits[6:10]
|
||||
return "(" + areaCode + ") " + centralOfficeCode + "-" + lineNumber
|
||||
}
|
||||
|
||||
// FormatZipCode formats a number as a US zip code (5 or 9 digits).
|
||||
func FormatZipCode(number any) string {
|
||||
num := formattersParseInt(formattersStringify(number))
|
||||
|
||||
if num <= 99999 {
|
||||
return formattersPadStartZero(strconv.Itoa(num), 5)
|
||||
}
|
||||
|
||||
digits := formattersPadStartZero(strconv.Itoa(num), 9)
|
||||
zipCode := digits[0:5]
|
||||
plus4 := digits[5:9]
|
||||
return zipCode + "-" + plus4
|
||||
}
|
||||
|
||||
// FormatTaxID formats a number as a US Tax ID (EIN): XX-XXXXXXX.
|
||||
func FormatTaxID(number any) string {
|
||||
digits := formattersPadStartZero(formattersDigits(formattersStringify(number)), 9)[:9]
|
||||
prefix := digits[0:2]
|
||||
identifier := digits[2:9]
|
||||
return prefix + "-" + identifier
|
||||
}
|
||||
|
||||
// formattersGroupThousands inserts ',' every three digits from the right of an
|
||||
// integer-digit string (no sign, no fraction).
|
||||
func formattersGroupThousands(intDigits string) string {
|
||||
n := len(intDigits)
|
||||
if n <= 3 {
|
||||
return intDigits
|
||||
}
|
||||
var b strings.Builder
|
||||
pre := n % 3
|
||||
if pre > 0 {
|
||||
b.WriteString(intDigits[:pre])
|
||||
}
|
||||
for i := pre; i < n; i += 3 {
|
||||
if b.Len() > 0 {
|
||||
b.WriteByte(',')
|
||||
}
|
||||
b.WriteString(intDigits[i : i+3])
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// formattersFormatGrouped renders n like Intl.NumberFormat("en-US") with the
|
||||
// given minimum/maximum fraction digits: rounds to maxFrac, trims trailing zeros
|
||||
// down to minFrac, and groups the integer part with commas.
|
||||
func formattersFormatGrouped(n float64, minFrac, maxFrac int) string {
|
||||
neg := math.Signbit(n)
|
||||
s := strconv.FormatFloat(math.Abs(n), 'f', maxFrac, 64)
|
||||
|
||||
intPart, fracPart := s, ""
|
||||
if dot := strings.IndexByte(s, '.'); dot >= 0 {
|
||||
intPart = s[:dot]
|
||||
fracPart = s[dot+1:]
|
||||
}
|
||||
|
||||
for len(fracPart) > minFrac && strings.HasSuffix(fracPart, "0") {
|
||||
fracPart = fracPart[:len(fracPart)-1]
|
||||
}
|
||||
|
||||
intPart = formattersGroupThousands(intPart)
|
||||
res := intPart
|
||||
if len(fracPart) > 0 {
|
||||
res += "." + fracPart
|
||||
}
|
||||
|
||||
// Only keep the sign when the rounded result is actually non-zero (avoids "-0").
|
||||
if neg && strings.ContainsFunc(res, func(r rune) bool { return r >= '1' && r <= '9' }) {
|
||||
res = "-" + res
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// FormatNumber formats a number with US thousands separators (Intl.NumberFormat
|
||||
// "en-US" defaults: 0 minimum and 3 maximum fraction digits).
|
||||
func FormatNumber(number float64) string {
|
||||
return formattersFormatGrouped(number, 0, 3)
|
||||
}
|
||||
|
||||
// FormatDecimal formats a number with US thousands separators and a fixed number
|
||||
// of fraction digits (default 2). Go has no default parameters, so decimalPlaces
|
||||
// is an optional variadic argument.
|
||||
func FormatDecimal(number float64, decimalPlaces ...int) string {
|
||||
dp := 2
|
||||
if len(decimalPlaces) > 0 {
|
||||
dp = decimalPlaces[0]
|
||||
}
|
||||
return formattersFormatGrouped(number, dp, dp)
|
||||
}
|
||||
|
||||
// State Code Utilities
|
||||
|
||||
var formattersStateCodeMap = map[string]string{
|
||||
"Alabama": "AL", "Alaska": "AK", "Arizona": "AZ", "Arkansas": "AR", "California": "CA",
|
||||
"Colorado": "CO", "Connecticut": "CT", "Delaware": "DE", "District of Columbia": "DC", "Florida": "FL",
|
||||
"Georgia": "GA", "Hawaii": "HI", "Idaho": "ID", "Illinois": "IL", "Indiana": "IN",
|
||||
"Iowa": "IA", "Kansas": "KS", "Kentucky": "KY", "Louisiana": "LA", "Maine": "ME",
|
||||
"Maryland": "MD", "Massachusetts": "MA", "Michigan": "MI", "Minnesota": "MN", "Mississippi": "MS",
|
||||
"Missouri": "MO", "Montana": "MT", "Nebraska": "NE", "Nevada": "NV", "New Hampshire": "NH",
|
||||
"New Jersey": "NJ", "New Mexico": "NM", "New York": "NY", "North Carolina": "NC", "North Dakota": "ND",
|
||||
"Ohio": "OH", "Oklahoma": "OK", "Oregon": "OR", "Pennsylvania": "PA", "Puerto Rico": "PR",
|
||||
"Rhode Island": "RI", "South Carolina": "SC", "South Dakota": "SD", "Tennessee": "TN", "Texas": "TX",
|
||||
"Utah": "UT", "Vermont": "VT", "Virgin Islands": "VI", "Virginia": "VA", "Washington": "WA",
|
||||
"West Virginia": "WV", "Wisconsin": "WI", "Wyoming": "WY",
|
||||
}
|
||||
|
||||
// StateToStateCode resolves a state name to its two-letter code, matching the
|
||||
// exact name first and then case-insensitively. Returns "" when unknown.
|
||||
func StateToStateCode(state string) string {
|
||||
if code, ok := formattersStateCodeMap[state]; ok {
|
||||
return code
|
||||
}
|
||||
|
||||
stateLower := strings.ToLower(state)
|
||||
for stateName, code := range formattersStateCodeMap {
|
||||
if strings.ToLower(stateName) == stateLower {
|
||||
return code
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// StateCodeToState resolves a two-letter code to its state name. Returns "" when unknown.
|
||||
func StateCodeToState(code string) string {
|
||||
up := strings.ToUpper(code)
|
||||
for state, stateCode := range formattersStateCodeMap {
|
||||
if stateCode == up {
|
||||
return state
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// IsValidStateCode reports whether code is a known two-letter state code.
|
||||
func IsValidStateCode(code string) bool {
|
||||
up := strings.ToUpper(code)
|
||||
for _, stateCode := range formattersStateCodeMap {
|
||||
if stateCode == up {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// formattersDateLayouts are the layouts tried when coercing a string into a
|
||||
// time.Time, standing in for JS `new Date(string)` (which cannot be reproduced
|
||||
// exactly). Ordered most-specific first.
|
||||
var formattersDateLayouts = []string{
|
||||
time.RFC3339Nano,
|
||||
time.RFC3339,
|
||||
"2006-01-02T15:04:05",
|
||||
"2006-01-02T15:04",
|
||||
"2006-01-02 15:04:05",
|
||||
"2006-01-02",
|
||||
"2006/01/02",
|
||||
"01/02/2006",
|
||||
time.RFC1123Z,
|
||||
time.RFC1123,
|
||||
time.ANSIC,
|
||||
}
|
||||
|
||||
// formattersToTime coerces the `string | Date` union into a time.Time. The bool
|
||||
// is false when a string could not be parsed (JS would produce "Invalid Date").
|
||||
func formattersToTime(date any) (time.Time, bool) {
|
||||
switch d := date.(type) {
|
||||
case time.Time:
|
||||
return d, true
|
||||
case string:
|
||||
for _, layout := range formattersDateLayouts {
|
||||
if t, err := time.Parse(layout, d); err == nil {
|
||||
return t, true
|
||||
}
|
||||
}
|
||||
return time.Time{}, false
|
||||
default:
|
||||
return time.Time{}, false
|
||||
}
|
||||
}
|
||||
|
||||
// FormatDate formats a date as MM/DD/YYYY. Returns "" for an unparseable string.
|
||||
func FormatDate(date any) string {
|
||||
d, ok := formattersToTime(date)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return d.Format("01/02/2006")
|
||||
}
|
||||
|
||||
// FormatDateLong formats a date as e.g. "January 2, 2006". Returns "" for an
|
||||
// unparseable string.
|
||||
func FormatDateLong(date any) string {
|
||||
d, ok := formattersToTime(date)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return d.Format("January 2, 2006")
|
||||
}
|
||||
|
||||
// FormatDateTime formats a date+time as e.g. "01/02/2006, 3:04 PM". Returns ""
|
||||
// for an unparseable string.
|
||||
func FormatDateTime(date any) string {
|
||||
d, ok := formattersToTime(date)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return d.Format("01/02/2006, 3:04 PM")
|
||||
}
|
||||
|
||||
// FormatPercent formats a value as a percentage with a fixed number of decimal
|
||||
// places. When isDecimal is true the value is treated as a fraction (0-1) and
|
||||
// scaled by 100. Go has no default parameters, so decimalPlaces and isDecimal
|
||||
// (defaults 2 and false in the TS source) are required here.
|
||||
func FormatPercent(value float64, decimalPlaces int, isDecimal bool) string {
|
||||
percent := value
|
||||
if isDecimal {
|
||||
percent = value * 100
|
||||
}
|
||||
return strconv.FormatFloat(percent, 'f', decimalPlaces, 64) + "%"
|
||||
}
|
||||
1264
go/webui/forms.go
Normal file
1264
go/webui/forms.go
Normal file
File diff suppressed because it is too large
Load Diff
392
go/webui/fuzzymatch.go
Normal file
392
go/webui/fuzzymatch.go
Normal file
@@ -0,0 +1,392 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"kjol/vdom"
|
||||
)
|
||||
|
||||
// Port of web/kit/FuzzyMatch.tsx.
|
||||
//
|
||||
// The fuzzy-scoring/matching logic (Forrest Smith's fts_fuzzy_match, Sublime-
|
||||
// style subsequence scoring) is pure and is ported directly to Go below. The
|
||||
// component keeps the API + structure + Tailwind and models query / open /
|
||||
// highlighted state as plain value props + callbacks.
|
||||
//
|
||||
// NOTE: the exported matcher `fuzzyMatch` would collide (case-folded) with the
|
||||
// `FuzzyMatch` component in Go, so it is renamed to FuzzyMatchOne here. The
|
||||
// other matchers keep their names (Go-cased): FuzzyMatchTypoTolerant,
|
||||
// FuzzySegments, RankFuzzyMatches.
|
||||
// NOTE: positions are rune indices (the TSX used UTF-16 code-unit indices);
|
||||
// these agree for BMP text and are what FuzzySegments consumes here.
|
||||
|
||||
// ============================================================================
|
||||
// Fuzzy matching (Sublime-style subsequence scoring)
|
||||
// ============================================================================
|
||||
|
||||
// FuzzyMatchResult is a scored match with the matched character positions.
|
||||
type FuzzyMatchResult struct {
|
||||
Score int
|
||||
Positions []int
|
||||
}
|
||||
|
||||
// FuzzySegment is a run of text flagged matched or unmatched (for highlighting).
|
||||
type FuzzySegment struct {
|
||||
Text string
|
||||
Match bool
|
||||
}
|
||||
|
||||
// FuzzyRankedItem is one ranked option: its value, score, and highlight segments.
|
||||
type FuzzyRankedItem struct {
|
||||
Value string
|
||||
Score int
|
||||
Segments []FuzzySegment
|
||||
}
|
||||
|
||||
const (
|
||||
fuzzySequentialBonus = 15
|
||||
fuzzySeparatorBonus = 30
|
||||
fuzzyCamelBonus = 30
|
||||
fuzzyFirstLetterBonus = 15
|
||||
fuzzyLeadingPenalty = -5
|
||||
fuzzyMaxLeadingPenalty = -15
|
||||
fuzzyUnmatchedPenalty = -1
|
||||
fuzzyRecursionLimit = 10
|
||||
fuzzyTransposePenalty = -20
|
||||
fuzzyExactSubstrBonus = 100
|
||||
)
|
||||
|
||||
func fuzzyIsLower(c rune) bool { return c >= 'a' && c <= 'z' }
|
||||
func fuzzyIsUpper(c rune) bool { return c >= 'A' && c <= 'Z' }
|
||||
func fuzzyIsSeparator(c rune) bool { return c == ' ' || c == '_' || c == '-' }
|
||||
|
||||
func fuzzyMax(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func fuzzyScore(target []rune, matches []int) int {
|
||||
score := 100
|
||||
score += fuzzyMax(fuzzyMaxLeadingPenalty, fuzzyLeadingPenalty*matches[0])
|
||||
score += fuzzyUnmatchedPenalty * (len(target) - len(matches))
|
||||
|
||||
for i := 0; i < len(matches); i++ {
|
||||
curr := matches[i]
|
||||
if i > 0 && curr == matches[i-1]+1 {
|
||||
score += fuzzySequentialBonus
|
||||
}
|
||||
if curr == 0 {
|
||||
score += fuzzyFirstLetterBonus
|
||||
} else {
|
||||
prev := target[curr-1]
|
||||
if fuzzyIsLower(prev) && fuzzyIsUpper(target[curr]) {
|
||||
score += fuzzyCamelBonus
|
||||
}
|
||||
if fuzzyIsSeparator(prev) {
|
||||
score += fuzzySeparatorBonus
|
||||
}
|
||||
}
|
||||
}
|
||||
return score
|
||||
}
|
||||
|
||||
func fuzzyRecurse(query, target []rune, qi, ti int, matches []int, rec *int) []int {
|
||||
*rec++
|
||||
if *rec >= fuzzyRecursionLimit {
|
||||
return nil
|
||||
}
|
||||
|
||||
var best []int
|
||||
for qi < len(query) && ti < len(target) {
|
||||
if unicode.ToLower(query[qi]) == unicode.ToLower(target[ti]) {
|
||||
skipped := fuzzyRecurse(query, target, qi, ti+1, append([]int(nil), matches...), rec)
|
||||
if skipped != nil && (best == nil || fuzzyScore(target, skipped) > fuzzyScore(target, best)) {
|
||||
best = skipped
|
||||
}
|
||||
matches = append(matches, ti)
|
||||
qi++
|
||||
}
|
||||
ti++
|
||||
}
|
||||
|
||||
if qi < len(query) {
|
||||
return best // query not fully consumed -> this path failed
|
||||
}
|
||||
if best == nil || fuzzyScore(target, matches) > fuzzyScore(target, best) {
|
||||
return matches
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// FuzzyMatchOne scores query against target, or returns nil if the query is
|
||||
// empty or is not a subsequence of target. (Port of the TSX `fuzzyMatch`.)
|
||||
func FuzzyMatchOne(query, target string) *FuzzyMatchResult {
|
||||
if query == "" {
|
||||
return nil
|
||||
}
|
||||
qr := []rune(query)
|
||||
tr := []rune(target)
|
||||
rec := 0
|
||||
matches := fuzzyRecurse(qr, tr, 0, 0, nil, &rec)
|
||||
if matches == nil {
|
||||
return nil
|
||||
}
|
||||
score := fuzzyScore(tr, matches)
|
||||
// A contiguous substring hit ("bankof" in "Bankof") should outrank a
|
||||
// word-boundary match split across tokens ("Bank of America"). The bonus is
|
||||
// constant per query/target, so it lives here rather than in the per-
|
||||
// alignment scorer the recursion uses to pick match positions.
|
||||
if strings.Contains(strings.ToLower(target), strings.ToLower(query)) {
|
||||
score += fuzzyExactSubstrBonus
|
||||
}
|
||||
return &FuzzyMatchResult{Score: score, Positions: matches}
|
||||
}
|
||||
|
||||
// FuzzyMatchTypoTolerant also tries every single adjacent-swap variant of the
|
||||
// query (subsequence matching can't tolerate a transposed typo like "teh" vs
|
||||
// "the"), penalizing transposed hits so exact matches still rank first.
|
||||
func FuzzyMatchTypoTolerant(query, target string) *FuzzyMatchResult {
|
||||
best := FuzzyMatchOne(query, target)
|
||||
qr := []rune(query)
|
||||
for i := 0; i < len(qr)-1; i++ {
|
||||
swapped := string(qr[:i]) + string(qr[i+1]) + string(qr[i]) + string(qr[i+2:])
|
||||
m := FuzzyMatchOne(swapped, target)
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
score := m.Score + fuzzyTransposePenalty
|
||||
if best == nil || score > best.Score {
|
||||
best = &FuzzyMatchResult{Score: score, Positions: m.Positions}
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// FuzzySegments splits text into alternating matched / unmatched runs for
|
||||
// highlighting, using the given (rune-index) positions.
|
||||
func FuzzySegments(text string, positions []int) []FuzzySegment {
|
||||
matched := make(map[int]bool, len(positions))
|
||||
for _, p := range positions {
|
||||
matched[p] = true
|
||||
}
|
||||
tr := []rune(text)
|
||||
var segments []FuzzySegment
|
||||
buf := ""
|
||||
bufMatch := matched[0]
|
||||
for i := 0; i < len(tr); i++ {
|
||||
isMatch := matched[i]
|
||||
if isMatch != bufMatch {
|
||||
if buf != "" {
|
||||
segments = append(segments, FuzzySegment{Text: buf, Match: bufMatch})
|
||||
}
|
||||
buf = ""
|
||||
bufMatch = isMatch
|
||||
}
|
||||
buf += string(tr[i])
|
||||
}
|
||||
if buf != "" {
|
||||
segments = append(segments, FuzzySegment{Text: buf, Match: bufMatch})
|
||||
}
|
||||
return segments
|
||||
}
|
||||
|
||||
// RankFuzzyMatches ranks options against query, best score first, with
|
||||
// highlight segments. Returns nil for an empty query. maxResults <= 0 means no
|
||||
// limit. This is the headless entry point.
|
||||
func RankFuzzyMatches(query string, options []string, maxResults int) []FuzzyRankedItem {
|
||||
q := strings.TrimSpace(query)
|
||||
if q == "" {
|
||||
return nil
|
||||
}
|
||||
var out []FuzzyRankedItem
|
||||
for _, value := range options {
|
||||
m := FuzzyMatchTypoTolerant(q, value)
|
||||
if m != nil {
|
||||
out = append(out, FuzzyRankedItem{Value: value, Score: m.Score, Segments: FuzzySegments(value, m.Positions)})
|
||||
}
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool { return out[i].Score > out[j].Score })
|
||||
if maxResults > 0 && len(out) > maxResults {
|
||||
out = out[:maxResults]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Component
|
||||
// ============================================================================
|
||||
|
||||
// FuzzyMatchDisplay selects how FuzzyMatch renders its results.
|
||||
type FuzzyMatchDisplay = string
|
||||
|
||||
const (
|
||||
FuzzyDisplayList = "list" // inline highlighted results below the input (default)
|
||||
FuzzyDisplayDropdown = "dropdown" // ComboBox-style autocomplete popover
|
||||
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"
|
||||
|
||||
// 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"
|
||||
|
||||
// FuzzyMatchProps configures the FuzzyMatch component. Query / Open / Highlighted
|
||||
// are caller-held state (read at the call site); the On* callbacks report changes.
|
||||
type FuzzyMatchProps struct {
|
||||
Options []string
|
||||
Display FuzzyMatchDisplay // default FuzzyDisplayList
|
||||
ShowScores bool
|
||||
MaxResults int
|
||||
Placeholder string
|
||||
Class string
|
||||
ListClass string
|
||||
|
||||
Query string // current query text
|
||||
Open bool // dropdown open (Display == dropdown)
|
||||
Highlighted int // highlighted result index (dropdown keyboard selection)
|
||||
|
||||
OnSelect func(value string, item FuzzyRankedItem)
|
||||
OnQueryChange func(query string)
|
||||
OnResults func(results []FuzzyRankedItem) // see NOTE: not auto-invoked
|
||||
}
|
||||
|
||||
// fuzzyHighlight renders a segment list as sky-highlighted / plain spans.
|
||||
func fuzzyHighlight(segments []FuzzySegment) []*vdom.VNode {
|
||||
out := make([]*vdom.VNode, 0, len(segments))
|
||||
for _, seg := range segments {
|
||||
if seg.Match {
|
||||
out = append(out, vdom.El("span", vdom.Attr("class", "text-sky-700 font-semibold"), vdom.Text(seg.Text)))
|
||||
} else {
|
||||
out = append(out, vdom.El("span", vdom.Text(seg.Text)))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// fuzzyScoreBadge renders the debug score badge, or nil when disabled.
|
||||
func fuzzyScoreBadge(show bool, score int) *vdom.VNode {
|
||||
if !show {
|
||||
return nil
|
||||
}
|
||||
return vdom.El("span", vdom.Attr("class", "ml-3 shrink-0 text-xs text-neutral-400"), vdom.Text(strconv.Itoa(score)))
|
||||
}
|
||||
|
||||
// fuzzyListView renders the inline "list" display. Before the user types, all
|
||||
// options show unranked so the searchable set is visible up front.
|
||||
func fuzzyListView(p FuzzyMatchProps, results []FuzzyRankedItem) *vdom.VNode {
|
||||
var items []FuzzyRankedItem
|
||||
if strings.TrimSpace(p.Query) != "" {
|
||||
items = results
|
||||
} else {
|
||||
for _, v := range p.Options {
|
||||
items = append(items, FuzzyRankedItem{Value: v, Score: 0, Segments: []FuzzySegment{{Text: v, Match: false}}})
|
||||
}
|
||||
}
|
||||
|
||||
outer := []vdom.Mod{vdom.Attr("class", cx("mt-3", pick(p.ListClass, "h-72 overflow-y-auto")))}
|
||||
if len(items) > 0 {
|
||||
ul := []vdom.Mod{vdom.Attr("class", "flex flex-col gap-0.5")}
|
||||
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"),
|
||||
}
|
||||
if p.OnSelect != nil {
|
||||
li = append(li, vdom.On(vdom.EVENT_CLICK, func() { p.OnSelect(r.Value, r) }))
|
||||
}
|
||||
li = append(li, vdom.El("span", kids([]vdom.Mod{vdom.Attr("class", "text-sm text-neutral-800")}, fuzzyHighlight(r.Segments))...))
|
||||
if badge := fuzzyScoreBadge(p.ShowScores, r.Score); badge != nil {
|
||||
li = append(li, badge)
|
||||
}
|
||||
ul = append(ul, vdom.El("li", li...))
|
||||
}
|
||||
outer = append(outer, vdom.El("ul", ul...))
|
||||
} else if strings.TrimSpace(p.Query) != "" {
|
||||
outer = append(outer, vdom.El("p",
|
||||
vdom.Attr("class", "text-sm text-neutral-500 italic"),
|
||||
vdom.Text(`No matches for "`+p.Query+`".`)))
|
||||
}
|
||||
return vdom.El("div", outer...)
|
||||
}
|
||||
|
||||
// fuzzyDropdownView renders the "dropdown" display's option panel.
|
||||
//
|
||||
// NOTE: the TSX portaled this to document.body with `position: fixed` coords
|
||||
// from getBoundingClientRect + a resize/scroll effect; here it's a statically
|
||||
// `absolute` panel anchored under the input (the container is `relative`).
|
||||
func fuzzyDropdownView(p FuzzyMatchProps, results []FuzzyRankedItem) *vdom.VNode {
|
||||
mods := []vdom.Mod{
|
||||
vdom.Attr("data-floating-content", "true"),
|
||||
vdom.Attr("class", cx("absolute left-0 right-0 top-full mt-1 z-[200]", fuzzyDropdownCls)),
|
||||
}
|
||||
for i, r := range results {
|
||||
r := r
|
||||
cls := fuzzyDropdownOptionCls
|
||||
if i == p.Highlighted {
|
||||
cls = cx(fuzzyDropdownOptionCls, fuzzyDropdownOptionHighlightCls)
|
||||
}
|
||||
btnMods := []vdom.Mod{
|
||||
vdom.Attr("type", "button"),
|
||||
vdom.Attr("class", cls),
|
||||
}
|
||||
if p.OnSelect != nil {
|
||||
btnMods = append(btnMods, vdom.On(vdom.EVENT_CLICK, func() { p.OnSelect(r.Value, r) }))
|
||||
}
|
||||
btnMods = append(btnMods, vdom.El("span", kids([]vdom.Mod{vdom.Attr("class", "text-neutral-800")}, fuzzyHighlight(r.Segments))...))
|
||||
if badge := fuzzyScoreBadge(p.ShowScores, r.Score); badge != nil {
|
||||
btnMods = append(btnMods, badge)
|
||||
}
|
||||
mods = append(mods, vdom.El("button", btnMods...))
|
||||
}
|
||||
return vdom.El("div", mods...)
|
||||
}
|
||||
|
||||
// FuzzyMatch renders a fuzzy-search input with inline ("list"), autocomplete
|
||||
// ("dropdown"), or headless ("none") result display.
|
||||
//
|
||||
// NOTE: keyboard navigation (ArrowUp/Down/Enter/Escape) is dropped — the vdom
|
||||
// Event exposes no key. In dropdown mode, updating Query and closing Open on
|
||||
// select, opening on focus/typing, and outside-click dismissal are the caller's
|
||||
// responsibility (report them via the On* callbacks). OnResults is retained for
|
||||
// API parity but is not auto-invoked; headless callers should call
|
||||
// RankFuzzyMatches directly.
|
||||
func FuzzyMatch(p FuzzyMatchProps) *vdom.VNode {
|
||||
display := p.Display
|
||||
if display == "" {
|
||||
display = FuzzyDisplayList
|
||||
}
|
||||
|
||||
results := RankFuzzyMatches(p.Query, p.Options, p.MaxResults)
|
||||
|
||||
inputMods := []vdom.Mod{
|
||||
vdom.Attr("type", "text"),
|
||||
vdom.Attr("class", fuzzyInputCls),
|
||||
vdom.Prop("value", p.Query),
|
||||
vdom.Attr("placeholder", pick(p.Placeholder, "Search...")),
|
||||
}
|
||||
if p.OnQueryChange != nil {
|
||||
inputMods = append(inputMods, vdom.OnEvent(vdom.EVENT_INPUT, func(e vdom.Event) { p.OnQueryChange(e.Value()) }))
|
||||
}
|
||||
|
||||
mods := []vdom.Mod{
|
||||
vdom.Attr("class", cx("relative", p.Class)),
|
||||
vdom.El("input", inputMods...),
|
||||
}
|
||||
|
||||
switch display {
|
||||
case FuzzyDisplayList:
|
||||
mods = append(mods, fuzzyListView(p, results))
|
||||
case FuzzyDisplayDropdown:
|
||||
if p.Open && len(results) > 0 {
|
||||
mods = append(mods, fuzzyDropdownView(p, results))
|
||||
}
|
||||
}
|
||||
return vdom.El("div", mods...)
|
||||
}
|
||||
92
go/webui/general.go
Normal file
92
go/webui/general.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package webui
|
||||
|
||||
import "kjol/vdom"
|
||||
|
||||
// Port of web/kit/General.tsx. Anchor components render plain <a href>; the app
|
||||
// wires SPA navigation on top (e.g. its layout nav uses Button/OnClick).
|
||||
|
||||
// PageContainer wraps a page body (marker class kept for app CSS).
|
||||
func PageContainer(children ...*vdom.VNode) *vdom.VNode {
|
||||
return vdom.El("div", kids([]vdom.Mod{vdom.Attr("class", "admin-page-container")}, children)...)
|
||||
}
|
||||
|
||||
// Divider is a thin horizontal rule.
|
||||
func Divider() *vdom.VNode { return vdom.El("hr", vdom.Attr("class", "text-neutral-200 mt-1 mb-3")) }
|
||||
|
||||
// CodeBox renders a dark monospace code block.
|
||||
func CodeBox(code, class string) *vdom.VNode {
|
||||
return vdom.El("div",
|
||||
vdom.Attr("class", cx("text-xs p-3 bg-neutral-800 text-neutral-100 border border-neutral-700 rounded-default", class)),
|
||||
vdom.El("pre", vdom.El("code", vdom.Text(code))),
|
||||
)
|
||||
}
|
||||
|
||||
// PageHeader is a centered page title with an underline.
|
||||
func PageHeader(text, class string) *vdom.VNode {
|
||||
return vdom.El("header", vdom.Attr("class", class),
|
||||
vdom.El("div", vdom.Attr("class", "mt-1"),
|
||||
vdom.El("h1", vdom.Attr("class", "text-center text-2xl font-light text-neutral-800 mb-2"), vdom.Text(text)),
|
||||
vdom.El("hr", vdom.Attr("class", "text-neutral-200 mb-2")),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// PageLink is an inline text link (blue). newTab opens in a new tab.
|
||||
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)),
|
||||
}
|
||||
if newTab {
|
||||
mods = append(mods, vdom.Attr("target", "_blank"), vdom.Attr("rel", "noopener noreferrer"))
|
||||
}
|
||||
return vdom.El("a", kids(mods, children)...)
|
||||
}
|
||||
|
||||
// Loader is a centered spinning ring.
|
||||
func Loader() *vdom.VNode {
|
||||
return vdom.El("div", vdom.Attr("class", "flex items-center justify-center p-8"),
|
||||
vdom.El("div", vdom.Attr("class", "h-8 w-8 border-4 border-sky-700 border-t-transparent rounded-full animate-spin")),
|
||||
)
|
||||
}
|
||||
|
||||
// BreadcrumbItem is one crumb (URL + display text).
|
||||
type BreadcrumbItem struct {
|
||||
URL string
|
||||
DisplayText string
|
||||
}
|
||||
|
||||
// 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")}
|
||||
for i, crumb := range items {
|
||||
if i != len(items)-1 {
|
||||
mods = append(mods, vdom.El("span", vdom.Attr("class", "flex items-center"),
|
||||
vdom.El("a", vdom.Attr("href", crumb.URL),
|
||||
vdom.Attr("class", "text-neutral-500 cursor-pointer no-underline hover:text-neutral-700 hover:underline"),
|
||||
vdom.Text(crumb.DisplayText)),
|
||||
Icon("chevron-right", 12, "mx-[0.15rem] opacity-50"),
|
||||
))
|
||||
} else {
|
||||
mods = append(mods, vdom.El("span", vdom.Attr("class", "text-neutral-700 font-medium"), vdom.Text(crumb.DisplayText)))
|
||||
}
|
||||
}
|
||||
return vdom.El("div", mods...)
|
||||
}
|
||||
|
||||
// ManagerPageHeader is a title/description row with an optional action slot
|
||||
// (marker classes kept for app CSS).
|
||||
func ManagerPageHeader(title, description string, action *vdom.VNode) *vdom.VNode {
|
||||
inner := vdom.El("div",
|
||||
vdom.El("h2", vdom.Attr("class", "page-title"), vdom.Text(title)),
|
||||
)
|
||||
if description != "" {
|
||||
inner.Children = append(inner.Children, vdom.El("p", vdom.Attr("class", "page-desc"), vdom.Text(description)))
|
||||
}
|
||||
mods := []vdom.Mod{vdom.Attr("class", "page-header"), inner}
|
||||
if action != nil {
|
||||
mods = append(mods, action)
|
||||
}
|
||||
return vdom.El("div", mods...)
|
||||
}
|
||||
94
go/webui/icons.go
Normal file
94
go/webui/icons.go
Normal file
@@ -0,0 +1,94 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"kjol/vdom"
|
||||
)
|
||||
|
||||
// Port of web/kit/Icons.tsx. The TSX resolves FontAwesome glyphs from an
|
||||
// app-generated registry (@appgen/faIcons). Here the registry is a package var
|
||||
// seeded with a small common set; apps add their own with RegisterIcon. Each
|
||||
// entry is the inner SVG markup for a 0 0 24 24 viewBox (Heroicons-style), so
|
||||
// the outer <svg> just sizes it.
|
||||
type iconDef struct {
|
||||
viewBox string
|
||||
content string
|
||||
}
|
||||
|
||||
var iconRegistry = map[string]iconDef{}
|
||||
|
||||
// RegisterIcon registers (or overrides) an icon. viewBox is like "0 0 24 24";
|
||||
// content is the inner SVG markup (e.g. one or more <path> elements).
|
||||
func RegisterIcon(name, viewBox, content string) {
|
||||
iconRegistry[name] = iconDef{viewBox: viewBox, content: content}
|
||||
}
|
||||
|
||||
// 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>.
|
||||
func Icon(name string, size int, class string) *vdom.VNode {
|
||||
if size <= 0 {
|
||||
size = 16
|
||||
}
|
||||
def, ok := iconRegistry[name]
|
||||
vb := "0 0 24 24"
|
||||
content := ""
|
||||
if ok {
|
||||
vb = def.viewBox
|
||||
content = def.content
|
||||
}
|
||||
s := strconv.Itoa(size)
|
||||
return vdom.El("svg",
|
||||
vdom.Attr("xmlns", "http://www.w3.org/2000/svg"),
|
||||
vdom.Attr("viewBox", vb),
|
||||
vdom.Attr("fill", "currentColor"),
|
||||
vdom.Attr("width", s), vdom.Attr("height", s),
|
||||
vdom.Attr("aria-hidden", "true"),
|
||||
vdom.Attr("class", cx("shrink-0", class)),
|
||||
vdom.Raw(content),
|
||||
)
|
||||
}
|
||||
|
||||
// IconInline is Icon with inline-block alignment (for use within text runs).
|
||||
func IconInline(name string, size int, class string) *vdom.VNode {
|
||||
return Icon(name, size, cx("inline-block align-middle", class))
|
||||
}
|
||||
|
||||
// IconSuccess / IconError are green/red inline convenience wrappers.
|
||||
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))
|
||||
}
|
||||
|
||||
// IconContainer is a flex row that vertically centers an icon + text.
|
||||
func IconContainer(children ...*vdom.VNode) *vdom.VNode {
|
||||
return vdom.El("span", kids([]vdom.Mod{vdom.Attr("class", "flex flex-row items-center gap-2")}, children)...)
|
||||
}
|
||||
|
||||
// A small default set of stroke-based icons (Heroicons outline, 24x24) so common
|
||||
// components render out of the box. Apps register more via RegisterIcon.
|
||||
func strokePath(d string) string {
|
||||
return `<path stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none" d="` + d + `"/>`
|
||||
}
|
||||
|
||||
func init() {
|
||||
reg := func(name, d string) { RegisterIcon(name, "0 0 24 24", strokePath(d)) }
|
||||
reg("chevron-left", "M15.75 19.5 8.25 12l7.5-7.5")
|
||||
reg("chevron-right", "m8.25 4.5 7.5 7.5-7.5 7.5")
|
||||
reg("chevron-down", "m19.5 8.25-7.5 7.5-7.5-7.5")
|
||||
reg("chevron-up", "m4.5 15.75 7.5-7.5 7.5 7.5")
|
||||
reg("check", "m4.5 12.75 6 6 9-13.5")
|
||||
reg("xmark", "M6 18 18 6M6 6l12 12")
|
||||
reg("x", "M6 18 18 6M6 6l12 12")
|
||||
reg("plus", "M12 4.5v15m7.5-7.5h-15")
|
||||
reg("minus", "M4.5 12h15")
|
||||
reg("bars", "M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5")
|
||||
reg("arrow-right", "M13.5 4.5 21 12m0 0-7.5 7.5M21 12H3")
|
||||
reg("external-link", "M13.5 6H5.25A2.25 2.25 0 0 0 3 8.25v10.5A2.25 2.25 0 0 0 5.25 21h10.5A2.25 2.25 0 0 0 18 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25")
|
||||
reg("info", "M11.25 11.25h1.5v4.5M12 8.25h.008M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z")
|
||||
reg("triangle-exclamation", "M12 9v3.75m0 3.75h.008M10.34 3.94 1.7 18a1.5 1.5 0 0 0 1.3 2.25h18a1.5 1.5 0 0 0 1.3-2.25L13.66 3.94a1.5 1.5 0 0 0-2.6 0Z")
|
||||
reg("circle-check", "M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z")
|
||||
}
|
||||
221
go/webui/menu.go
Normal file
221
go/webui/menu.go
Normal file
@@ -0,0 +1,221 @@
|
||||
package webui
|
||||
|
||||
import "kjol/vdom"
|
||||
|
||||
// Port of web/kit/Menu.tsx.
|
||||
//
|
||||
// NOTE: floating-ui positioning (FloatingRoot / FloatingTrigger / FloatingContent
|
||||
// / useFloatingContext / useFloatingHover) is dropped. Menu is a `relative`
|
||||
// wrapper and MenuContent is an `absolute` dropdown positioned with static
|
||||
// Tailwind utilities chosen from MenuPlacement; there is no viewport-aware
|
||||
// collision detection.
|
||||
// NOTE: Solid's MenuContext (closeMenu / openOnHover / cancelParentClose) is
|
||||
// dropped. Open state is a passed bool: MenuContent and Submenu render only when
|
||||
// open, and the caller toggles it via MenuTrigger's onToggle. Item clicks no
|
||||
// longer auto-close the menu (closeOnClick / closeMenu removed) — the caller
|
||||
// closes it from its own click handler.
|
||||
// NOTE: hover-open/hover-close timers, the MenuTrigger render-prop (isOpen state)
|
||||
// and asChild, Submenu's getBoundingClientRect positioning with scroll/resize
|
||||
// listeners, and MenuItem's Enter/Space keydown handler (the runtime's Event
|
||||
// exposes no key) are all dropped.
|
||||
// NOTE: the imported "Placement" type is renamed MenuPlacement to avoid colliding
|
||||
// with a future Floating port.
|
||||
|
||||
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"
|
||||
|
||||
// MenuPlacement selects where MenuContent is positioned relative to its trigger.
|
||||
type MenuPlacement string
|
||||
|
||||
const (
|
||||
MenuPlacementBottomStart MenuPlacement = "bottom-start"
|
||||
MenuPlacementBottomEnd MenuPlacement = "bottom-end"
|
||||
MenuPlacementTopStart MenuPlacement = "top-start"
|
||||
MenuPlacementTopEnd MenuPlacement = "top-end"
|
||||
MenuPlacementLeftStart MenuPlacement = "left-start"
|
||||
MenuPlacementRightStart MenuPlacement = "right-start"
|
||||
)
|
||||
|
||||
// menuPlacementClasses maps a placement to the static absolute-position utilities
|
||||
// (the ~4px offset becomes the mt-1/mb-1/ml-1/mr-1 margin).
|
||||
var menuPlacementClasses = map[MenuPlacement]string{
|
||||
MenuPlacementBottomStart: "top-full left-0 mt-1",
|
||||
MenuPlacementBottomEnd: "top-full right-0 mt-1",
|
||||
MenuPlacementTopStart: "bottom-full left-0 mb-1",
|
||||
MenuPlacementTopEnd: "bottom-full right-0 mb-1",
|
||||
MenuPlacementLeftStart: "right-full top-0 mr-1",
|
||||
MenuPlacementRightStart: "left-full top-0 ml-1",
|
||||
}
|
||||
|
||||
// Menu is the positioning context: a relative wrapper holding a MenuTrigger and a
|
||||
// MenuContent.
|
||||
func Menu(class string, children ...*vdom.VNode) *vdom.VNode {
|
||||
return vdom.El("div", kids([]vdom.Mod{vdom.Attr("class", cx("relative inline-block", class))}, children)...)
|
||||
}
|
||||
|
||||
// MenuTrigger wraps the clickable element that toggles the menu open/closed.
|
||||
func MenuTrigger(onToggle func(), class string, children ...*vdom.VNode) *vdom.VNode {
|
||||
mods := []vdom.Mod{
|
||||
vdom.Attr("class", class),
|
||||
vdom.Attr("aria-haspopup", "menu"),
|
||||
}
|
||||
if onToggle != nil {
|
||||
mods = append(mods, vdom.On(vdom.EVENT_CLICK, onToggle))
|
||||
}
|
||||
return vdom.El("div", kids(mods, children)...)
|
||||
}
|
||||
|
||||
// MenuContent is the dropdown panel; it renders only when open, positioned with
|
||||
// static Tailwind utilities for the given placement.
|
||||
func MenuContent(open bool, placement MenuPlacement, class string, children ...*vdom.VNode) *vdom.VNode {
|
||||
if !open {
|
||||
return nil
|
||||
}
|
||||
pos := menuPlacementClasses[placement]
|
||||
if pos == "" {
|
||||
pos = menuPlacementClasses[MenuPlacementBottomStart]
|
||||
}
|
||||
mods := []vdom.Mod{
|
||||
vdom.Attr("class", cx("absolute z-50", pos, menuCls, class)),
|
||||
vdom.Attr("role", "menu"),
|
||||
}
|
||||
return vdom.El("div", kids(mods, children)...)
|
||||
}
|
||||
|
||||
// MenuItemProps configures MenuItem.
|
||||
type MenuItemProps struct {
|
||||
Icon string
|
||||
Disabled bool
|
||||
OnClick func()
|
||||
Class string
|
||||
}
|
||||
|
||||
// MenuItem is a <button> menu entry.
|
||||
func MenuItem(p MenuItemProps, children ...*vdom.VNode) *vdom.VNode {
|
||||
mods := []vdom.Mod{
|
||||
vdom.Attr("type", "button"),
|
||||
vdom.Attr("role", "menuitem"),
|
||||
vdom.Attr("class", cx(menuItemCls, p.Class)),
|
||||
}
|
||||
if p.Disabled {
|
||||
mods = append(mods, vdom.Attr("disabled", "disabled"))
|
||||
} else if p.OnClick != nil {
|
||||
mods = append(mods, vdom.On(vdom.EVENT_CLICK, p.OnClick))
|
||||
}
|
||||
if p.Icon != "" {
|
||||
mods = append(mods, Icon(p.Icon, 16, "shrink-0"))
|
||||
}
|
||||
return vdom.El("button", kids(mods, children)...)
|
||||
}
|
||||
|
||||
// MenuLink is an anchor menu entry for internal navigation.
|
||||
func MenuLink(href, icon, class string, children ...*vdom.VNode) *vdom.VNode {
|
||||
mods := []vdom.Mod{
|
||||
vdom.Attr("href", href),
|
||||
vdom.Attr("role", "menuitem"),
|
||||
vdom.Attr("class", cx(menuItemCls, class)),
|
||||
}
|
||||
if icon != "" {
|
||||
mods = append(mods, Icon(icon, 16, "shrink-0"))
|
||||
}
|
||||
return vdom.El("a", kids(mods, children)...)
|
||||
}
|
||||
|
||||
// MenuAnchorProps configures MenuAnchor.
|
||||
type MenuAnchorProps struct {
|
||||
Href string
|
||||
Icon string
|
||||
Target string
|
||||
Rel string
|
||||
Class string
|
||||
}
|
||||
|
||||
// MenuAnchor is an anchor menu entry to an external URL (Target defaults to
|
||||
// _blank, Rel to noopener noreferrer). A _blank target appends a trailing arrow.
|
||||
func MenuAnchor(p MenuAnchorProps, children ...*vdom.VNode) *vdom.VNode {
|
||||
target := pick(p.Target, "_blank")
|
||||
rel := pick(p.Rel, "noopener noreferrer")
|
||||
mods := []vdom.Mod{
|
||||
vdom.Attr("href", p.Href),
|
||||
vdom.Attr("target", target),
|
||||
vdom.Attr("rel", rel),
|
||||
vdom.Attr("role", "menuitem"),
|
||||
vdom.Attr("class", cx(menuItemCls, p.Class)),
|
||||
}
|
||||
if p.Icon != "" {
|
||||
mods = append(mods, Icon(p.Icon, 16, "shrink-0"))
|
||||
}
|
||||
mods = kids(mods, children)
|
||||
if target == "_blank" {
|
||||
mods = append(mods, Icon("arrow-right", 12, "shrink-0 ml-auto text-neutral-400"))
|
||||
}
|
||||
return vdom.El("a", mods...)
|
||||
}
|
||||
|
||||
// MenuDivider is a horizontal separator between groups of items.
|
||||
func MenuDivider(class string) *vdom.VNode {
|
||||
return vdom.El("hr", vdom.Attr("class", cx(menuDividerCls, class)), vdom.Attr("role", "separator"))
|
||||
}
|
||||
|
||||
// MenuSection is an uppercase section label.
|
||||
func MenuSection(class string, children ...*vdom.VNode) *vdom.VNode {
|
||||
mods := []vdom.Mod{vdom.Attr("class", cx(menuSectionCls, class)), vdom.Attr("role", "presentation")}
|
||||
return vdom.El("div", kids(mods, children)...)
|
||||
}
|
||||
|
||||
// MenuGroup groups related items together (role=group).
|
||||
func MenuGroup(class string, children ...*vdom.VNode) *vdom.VNode {
|
||||
mods := []vdom.Mod{vdom.Attr("role", "group"), vdom.Attr("class", class)}
|
||||
return vdom.El("div", kids(mods, children)...)
|
||||
}
|
||||
|
||||
// SubmenuProps configures Submenu.
|
||||
type SubmenuProps struct {
|
||||
Open bool
|
||||
OnToggle func()
|
||||
Trigger string
|
||||
Icon string
|
||||
Class string
|
||||
}
|
||||
|
||||
// Submenu is a nested menu opened from a parent item. When Open, its panel
|
||||
// renders to the right of the trigger via static Tailwind (no measurement).
|
||||
func Submenu(p SubmenuProps, children ...*vdom.VNode) *vdom.VNode {
|
||||
ariaExpanded := "false"
|
||||
if p.Open {
|
||||
ariaExpanded = "true"
|
||||
}
|
||||
|
||||
label := []vdom.Mod{vdom.Attr("class", "flex items-center gap-2")}
|
||||
if p.Icon != "" {
|
||||
label = append(label, Icon(p.Icon, 16, "shrink-0"))
|
||||
}
|
||||
label = append(label, vdom.Text(p.Trigger))
|
||||
|
||||
triggerMods := []vdom.Mod{
|
||||
vdom.Attr("role", "menuitem"),
|
||||
vdom.Attr("aria-haspopup", "menu"),
|
||||
vdom.Attr("aria-expanded", ariaExpanded),
|
||||
vdom.Attr("class", cx(menuSubmenuTriggerCls, p.Class)),
|
||||
}
|
||||
if p.OnToggle != nil {
|
||||
triggerMods = append(triggerMods, vdom.On(vdom.EVENT_CLICK, p.OnToggle))
|
||||
}
|
||||
triggerMods = append(triggerMods,
|
||||
vdom.El("span", label...),
|
||||
Icon("chevron-right", 16, "shrink-0 ml-auto text-neutral-400"),
|
||||
)
|
||||
|
||||
wrap := []vdom.Mod{vdom.Attr("class", "relative"), vdom.El("div", triggerMods...)}
|
||||
if p.Open {
|
||||
contentMods := []vdom.Mod{
|
||||
vdom.Attr("role", "menu"),
|
||||
vdom.Attr("class", cx("absolute left-full top-0 ml-1 z-[51]", menuCls)),
|
||||
}
|
||||
wrap = append(wrap, vdom.El("div", kids(contentMods, children)...))
|
||||
}
|
||||
return vdom.El("div", wrap...)
|
||||
}
|
||||
432
go/webui/modal.go
Normal file
432
go/webui/modal.go
Normal file
@@ -0,0 +1,432 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"kjol/vdom"
|
||||
)
|
||||
|
||||
// Port of web/kit/Modal.tsx.
|
||||
//
|
||||
// NOTE: Solid's Portal (render to document.body) is dropped — modals render
|
||||
// inline where they are placed. Callers should mount them near the page root so
|
||||
// the fixed-position container is not clipped by an ancestor's overflow/transform.
|
||||
// NOTE: the entrance/exit animations (the isVisible signal plus the inline
|
||||
// opacity/scale transition styles) are dropped; the modal renders in its final
|
||||
// visible state.
|
||||
// NOTE: the imperative context (ModalProvider / useModal / openModal(content))
|
||||
// and the Escape-key handling (useModalEscape + the shared open-modal stack,
|
||||
// which needs a document keydown listener) are dropped — there is no context or
|
||||
// document access here. Use Modal with an IsOpen bool + OnClose callback instead.
|
||||
// NOTE: WizardStepContext (setCanContinue/nextStep/prevStep passed into each
|
||||
// step) is dropped. A WizardStep now carries a static Content node; the caller
|
||||
// drives CurrentStep/CanContinue via props. The openVersion reset-on-open memo
|
||||
// is likewise unnecessary here.
|
||||
// NOTE: the undefined-vs-null header/footer distinction collapses to nil — a nil
|
||||
// Header renders the close-only header (the undefined default); there is no
|
||||
// "explicitly no header" case. The "circle-exclamation" wizard-error icon is not
|
||||
// in the default registry and renders as an empty box until an app registers it.
|
||||
|
||||
// ModalSize selects the modal panel's max width.
|
||||
type ModalSize string
|
||||
|
||||
const (
|
||||
ModalSmall ModalSize = "small"
|
||||
ModalDefault ModalSize = "default"
|
||||
ModalMedium ModalSize = "medium"
|
||||
ModalLarge ModalSize = "large"
|
||||
ModalXLarge ModalSize = "xlarge"
|
||||
Modal2XLarge ModalSize = "2xlarge"
|
||||
Modal3XLarge ModalSize = "3xlarge"
|
||||
Modal4XLarge ModalSize = "4xlarge"
|
||||
Modal5XLarge ModalSize = "5xlarge"
|
||||
ModalFull ModalSize = "full"
|
||||
)
|
||||
|
||||
// -- Tailwind class constants --------------------------------------
|
||||
const modalContainerBase = "fixed inset-0 z-[100] w-full h-dvh m-0 border-0 bg-transparent flex justify-center max-w-screen max-h-dvh"
|
||||
const modalContainerTop = "items-start pt-10"
|
||||
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"
|
||||
|
||||
var modalSizes = map[ModalSize]string{
|
||||
ModalSmall: "max-w-md",
|
||||
ModalDefault: "max-w-xl",
|
||||
ModalMedium: "max-w-2xl",
|
||||
ModalLarge: "max-w-3xl",
|
||||
ModalXLarge: "max-w-4xl",
|
||||
Modal2XLarge: "max-w-5xl",
|
||||
Modal3XLarge: "max-w-6xl",
|
||||
Modal4XLarge: "max-w-7xl",
|
||||
Modal5XLarge: "max-w-[90rem]",
|
||||
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 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 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 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"
|
||||
|
||||
// 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 modalConfirmOkBase = "py-2 px-4 text-sm rounded-default border-0 cursor-pointer text-white"
|
||||
|
||||
var modalConfirmOkVariants = map[string]string{
|
||||
"danger": "bg-red-600 hover:bg-red-700",
|
||||
"primary": "bg-primary hover:bg-primary-hover",
|
||||
}
|
||||
|
||||
// Wizard header
|
||||
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 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 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 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 modalWizardBtnNext = "bg-neutral-800 text-white enabled:hover:bg-neutral-900"
|
||||
const modalWizardBtnFinish = "bg-primary text-white enabled:hover:bg-red-700"
|
||||
|
||||
// modalCloseButton is the shared "✕" button (dismisses via onClose).
|
||||
func modalCloseButton(onClose func(), size int) *vdom.VNode {
|
||||
mods := []vdom.Mod{vdom.Attr("class", modalCloseBtn)}
|
||||
if onClose != nil {
|
||||
mods = append(mods, vdom.On(vdom.EVENT_CLICK, onClose))
|
||||
}
|
||||
mods = append(mods, Icon("xmark", size, ""))
|
||||
return vdom.El("button", mods...)
|
||||
}
|
||||
|
||||
func modalCloseOnlyHeader(onClose func()) *vdom.VNode {
|
||||
return vdom.El("div", vdom.Attr("class", modalHeaderCloseOnly), modalCloseButton(onClose, 24))
|
||||
}
|
||||
|
||||
func modalFullHeader(header *vdom.VNode, onClose func()) *vdom.VNode {
|
||||
return vdom.El("div", vdom.Attr("class", modalHeader), header, modalCloseButton(onClose, 24))
|
||||
}
|
||||
|
||||
// modalDisplay is the dialog + backdrop + panel wrapper. Clicking the backdrop
|
||||
// invokes onClose.
|
||||
func modalDisplay(size ModalSize, centerOnScreen bool, onClose func(), children ...*vdom.VNode) *vdom.VNode {
|
||||
align := modalContainerTop
|
||||
if centerOnScreen {
|
||||
align = modalContainerCenter
|
||||
}
|
||||
if size == "" {
|
||||
size = ModalDefault
|
||||
}
|
||||
|
||||
backdrop := []vdom.Mod{vdom.Attr("class", modalBackdrop)}
|
||||
if onClose != nil {
|
||||
backdrop = append(backdrop, vdom.On(vdom.EVENT_CLICK, onClose))
|
||||
}
|
||||
|
||||
panel := kids([]vdom.Mod{vdom.Attr("class", cx(modalBase, modalSizes[size]))}, children)
|
||||
|
||||
return vdom.El("dialog",
|
||||
vdom.Attr("open", "open"),
|
||||
vdom.Attr("class", cx(modalContainerBase, align)),
|
||||
vdom.El("div", backdrop...),
|
||||
vdom.El("div", panel...),
|
||||
)
|
||||
}
|
||||
|
||||
// modalContentNodes builds the header / body / footer panel children shared by
|
||||
// Modal and ModalContent. A nil header renders the close-only header; a nil
|
||||
// footer renders the spacer.
|
||||
func modalContentNodes(header, footer *vdom.VNode, onClose func(), children []*vdom.VNode) []*vdom.VNode {
|
||||
var nodes []*vdom.VNode
|
||||
if header == nil {
|
||||
nodes = append(nodes, modalCloseOnlyHeader(onClose))
|
||||
} else {
|
||||
nodes = append(nodes, modalFullHeader(header, onClose))
|
||||
}
|
||||
nodes = append(nodes, vdom.El("div", kids([]vdom.Mod{vdom.Attr("class", modalBody)}, children)...))
|
||||
if footer == nil {
|
||||
nodes = append(nodes, vdom.El("div", vdom.Attr("class", modalFooterSpacer)))
|
||||
} else {
|
||||
nodes = append(nodes, vdom.El("div", vdom.Attr("class", modalFooter), footer))
|
||||
}
|
||||
return nodes
|
||||
}
|
||||
|
||||
// ModalProps configures Modal. A nil Header renders a close-only header; a nil
|
||||
// Footer renders a spacer.
|
||||
type ModalProps struct {
|
||||
IsOpen bool
|
||||
OnClose func()
|
||||
Size ModalSize
|
||||
CenterOnScreen bool
|
||||
Header *vdom.VNode
|
||||
Footer *vdom.VNode
|
||||
}
|
||||
|
||||
// Modal renders a dialog when IsOpen; otherwise it renders nothing (nil).
|
||||
func Modal(p ModalProps, children ...*vdom.VNode) *vdom.VNode {
|
||||
if !p.IsOpen {
|
||||
return nil
|
||||
}
|
||||
nodes := modalContentNodes(p.Header, p.Footer, p.OnClose, children)
|
||||
return modalDisplay(p.Size, p.CenterOnScreen, p.OnClose, nodes...)
|
||||
}
|
||||
|
||||
// ModalContentProps configures ModalContent.
|
||||
type ModalContentProps struct {
|
||||
Header *vdom.VNode
|
||||
Footer *vdom.VNode
|
||||
OnClose func()
|
||||
}
|
||||
|
||||
// ModalContent renders the header / body / footer trio to drop inside a
|
||||
// modalDisplay panel. The TSX returned a fragment; here it is wrapped in a
|
||||
// display:contents div so it adds no layout box.
|
||||
func ModalContent(p ModalContentProps, children ...*vdom.VNode) *vdom.VNode {
|
||||
nodes := modalContentNodes(p.Header, p.Footer, p.OnClose, children)
|
||||
mods := kids([]vdom.Mod{vdom.Attr("class", "contents")}, nodes)
|
||||
return vdom.El("div", mods...)
|
||||
}
|
||||
|
||||
// ConfirmModalProps configures ConfirmModal. ConfirmStyle is "danger" (default)
|
||||
// or "primary".
|
||||
type ConfirmModalProps struct {
|
||||
IsOpen bool
|
||||
OnClose func()
|
||||
OnConfirm func()
|
||||
Title string
|
||||
Message string
|
||||
ConfirmText string
|
||||
CancelText string
|
||||
ConfirmStyle string
|
||||
}
|
||||
|
||||
// ConfirmModal is a small centered modal with cancel/confirm buttons.
|
||||
func ConfirmModal(p ConfirmModalProps) *vdom.VNode {
|
||||
if !p.IsOpen {
|
||||
return nil
|
||||
}
|
||||
title := pick(p.Title, "Confirm")
|
||||
confirmText := pick(p.ConfirmText, "Confirm")
|
||||
cancelText := pick(p.CancelText, "Cancel")
|
||||
style := pick(p.ConfirmStyle, "danger")
|
||||
okVariant := modalConfirmOkVariants[style]
|
||||
if okVariant == "" {
|
||||
okVariant = modalConfirmOkVariants["danger"]
|
||||
}
|
||||
|
||||
cancelMods := []vdom.Mod{vdom.Attr("class", modalConfirmCancel)}
|
||||
if p.OnClose != nil {
|
||||
cancelMods = append(cancelMods, vdom.On(vdom.EVENT_CLICK, p.OnClose))
|
||||
}
|
||||
cancelMods = append(cancelMods, vdom.Text(cancelText))
|
||||
|
||||
okMods := []vdom.Mod{
|
||||
vdom.Attr("class", cx(modalConfirmOkBase, okVariant)),
|
||||
vdom.On(vdom.EVENT_CLICK, func() {
|
||||
if p.OnConfirm != nil {
|
||||
p.OnConfirm()
|
||||
}
|
||||
if p.OnClose != nil {
|
||||
p.OnClose()
|
||||
}
|
||||
}),
|
||||
vdom.Text(confirmText),
|
||||
}
|
||||
|
||||
footer := vdom.El("div", vdom.Attr("class", modalConfirmWrap),
|
||||
vdom.El("button", cancelMods...),
|
||||
vdom.El("button", okMods...),
|
||||
)
|
||||
|
||||
return Modal(ModalProps{
|
||||
IsOpen: true,
|
||||
OnClose: p.OnClose,
|
||||
Size: ModalSmall,
|
||||
CenterOnScreen: true,
|
||||
Header: vdom.Text(title),
|
||||
Footer: footer,
|
||||
}, vdom.Text(p.Message))
|
||||
}
|
||||
|
||||
// WizardStep is one step of a WizardModal. Content is rendered statically (the
|
||||
// TSX's per-step callback context is dropped — see the file NOTE).
|
||||
type WizardStep struct {
|
||||
Title string
|
||||
Content *vdom.VNode
|
||||
}
|
||||
|
||||
// WizardModalProps configures WizardModal. CurrentStep + OnStepChange drive
|
||||
// navigation; CanContinue gates the Next/Finish button for the current step.
|
||||
type WizardModalProps struct {
|
||||
IsOpen bool
|
||||
OnClose func()
|
||||
OnComplete func()
|
||||
Steps []WizardStep
|
||||
CurrentStep int
|
||||
OnStepChange func(int)
|
||||
CanContinue bool
|
||||
Size ModalSize
|
||||
CenterOnScreen bool
|
||||
Title string
|
||||
FinishText string
|
||||
Error string
|
||||
}
|
||||
|
||||
func modalStepIndicatorClass(i, cur int) string {
|
||||
switch {
|
||||
case i == cur:
|
||||
return cx(modalStepIndicatorBase, modalStepIndicatorActive)
|
||||
case i < cur:
|
||||
return cx(modalStepIndicatorBase, modalStepIndicatorCompleted)
|
||||
default:
|
||||
return cx(modalStepIndicatorBase, modalStepIndicatorPending)
|
||||
}
|
||||
}
|
||||
|
||||
func modalWizardNextBtnClass(isLast bool) string {
|
||||
if isLast {
|
||||
return cx(modalWizardBtnBase, modalWizardBtnFinish)
|
||||
}
|
||||
return cx(modalWizardBtnBase, modalWizardBtnNext)
|
||||
}
|
||||
|
||||
// WizardModal is a multi-step modal with a progress header and Back/Next footer.
|
||||
func WizardModal(p WizardModalProps) *vdom.VNode {
|
||||
if !p.IsOpen {
|
||||
return nil
|
||||
}
|
||||
steps := p.Steps
|
||||
total := len(steps)
|
||||
cur := p.CurrentStep
|
||||
size := p.Size
|
||||
if size == "" {
|
||||
size = ModalLarge
|
||||
}
|
||||
finishText := pick(p.FinishText, "Finish")
|
||||
isFirst := cur == 0
|
||||
isLast := cur == total-1
|
||||
|
||||
title := p.Title
|
||||
currentStepTitle := ""
|
||||
if cur >= 0 && cur < total {
|
||||
currentStepTitle = steps[cur].Title
|
||||
if title == "" {
|
||||
title = steps[cur].Title
|
||||
}
|
||||
}
|
||||
|
||||
pct := 100.0
|
||||
if total > 1 {
|
||||
pct = float64(cur) / float64(total-1) * 100
|
||||
}
|
||||
pctStr := strconv.FormatFloat(pct, 'f', -1, 64)
|
||||
|
||||
// Progress track + step indicators.
|
||||
stepsRow := []vdom.Mod{
|
||||
vdom.Attr("class", modalWizardSteps),
|
||||
vdom.El("div", vdom.Attr("class", modalWizardTrack),
|
||||
vdom.El("div", vdom.Attr("class", modalWizardTrackFill), vdom.Attr("style", "width:"+pctStr+"%")),
|
||||
),
|
||||
}
|
||||
for i := range steps {
|
||||
label := strconv.Itoa(i + 1)
|
||||
if i < cur {
|
||||
label = "✓"
|
||||
}
|
||||
stepsRow = append(stepsRow, vdom.El("div", vdom.Attr("class", modalWizardStepWrap),
|
||||
vdom.El("div", vdom.Attr("class", modalStepIndicatorClass(i, cur)), vdom.Text(label)),
|
||||
))
|
||||
}
|
||||
|
||||
titleRow := vdom.El("div", vdom.Attr("class", modalWizardTitleRow),
|
||||
vdom.El("span", vdom.Attr("class", modalWizardTitle), vdom.Text(title)),
|
||||
modalCloseButton(p.OnClose, 24),
|
||||
)
|
||||
wizardHeader := vdom.El("div", vdom.Attr("class", modalWizardHeader),
|
||||
titleRow,
|
||||
vdom.El("div", vdom.Attr("class", modalWizardStepName), vdom.Text(currentStepTitle)),
|
||||
vdom.El("div", stepsRow...),
|
||||
)
|
||||
headerDiv := vdom.El("div", vdom.Attr("class", modalHeader), wizardHeader)
|
||||
|
||||
// Body: render every step, hiding the non-current ones.
|
||||
bodyMods := []vdom.Mod{vdom.Attr("class", modalBody)}
|
||||
for i, s := range steps {
|
||||
itemMods := []vdom.Mod{}
|
||||
if i != cur {
|
||||
itemMods = append(itemMods, vdom.Attr("style", "display:none"))
|
||||
}
|
||||
if s.Content != nil {
|
||||
itemMods = append(itemMods, s.Content)
|
||||
}
|
||||
bodyMods = append(bodyMods, vdom.El("div", itemMods...))
|
||||
}
|
||||
|
||||
panel := []*vdom.VNode{headerDiv, vdom.El("div", bodyMods...)}
|
||||
|
||||
if p.Error != "" {
|
||||
panel = append(panel, vdom.El("div", vdom.Attr("class", modalWizardError),
|
||||
Icon("circle-exclamation", 16, modalWizardErrorIcon),
|
||||
vdom.El("span", vdom.Text(p.Error)),
|
||||
))
|
||||
}
|
||||
|
||||
// Footer: Back / Next(Finish).
|
||||
backMods := []vdom.Mod{vdom.Attr("class", cx(modalWizardBtnBase, modalWizardBtnBack))}
|
||||
if isFirst {
|
||||
backMods = append(backMods, vdom.Attr("disabled", "disabled"))
|
||||
}
|
||||
if p.OnStepChange != nil {
|
||||
backMods = append(backMods, vdom.On(vdom.EVENT_CLICK, func() {
|
||||
if !isFirst {
|
||||
p.OnStepChange(cur - 1)
|
||||
}
|
||||
}))
|
||||
}
|
||||
backMods = append(backMods, vdom.Text("Back"))
|
||||
|
||||
nextMods := []vdom.Mod{vdom.Attr("class", modalWizardNextBtnClass(isLast))}
|
||||
if !p.CanContinue {
|
||||
nextMods = append(nextMods, vdom.Attr("disabled", "disabled"))
|
||||
}
|
||||
nextMods = append(nextMods, vdom.On(vdom.EVENT_CLICK, func() {
|
||||
if isLast {
|
||||
if p.OnComplete != nil {
|
||||
p.OnComplete()
|
||||
}
|
||||
} else if p.OnStepChange != nil {
|
||||
p.OnStepChange(cur + 1)
|
||||
}
|
||||
}))
|
||||
nextLabel := "Next"
|
||||
if isLast {
|
||||
nextLabel = finishText
|
||||
}
|
||||
nextMods = append(nextMods, vdom.Text(nextLabel))
|
||||
|
||||
footerInner := vdom.El("div", vdom.Attr("class", modalWizardFooter),
|
||||
vdom.El("button", backMods...),
|
||||
vdom.El("button", nextMods...),
|
||||
)
|
||||
panel = append(panel, vdom.El("div", vdom.Attr("class", modalFooter), footerInner))
|
||||
|
||||
return modalDisplay(size, p.CenterOnScreen, p.OnClose, panel...)
|
||||
}
|
||||
165
go/webui/popovers.go
Normal file
165
go/webui/popovers.go
Normal file
@@ -0,0 +1,165 @@
|
||||
// Port of web/kit/Popovers.tsx.
|
||||
//
|
||||
// NOTE: the TSX builds these on Floating.tsx — a floating-ui-style layer with
|
||||
// getBoundingClientRect measurement, requestAnimationFrame reposition, a Portal
|
||||
// to document.body, a global single-open manager, outside-click/Escape handling,
|
||||
// and hover open/close timers, all threaded through a FloatingContext. None of
|
||||
// that has an equivalent in the neutral runtime (no document, portals, refs,
|
||||
// element measurement, or timers), so it is dropped. This port keeps the
|
||||
// component API + Tailwind + event wiring, drives click open/close with a
|
||||
// caller-supplied `open` bool + toggle callback, does hover reveal purely in CSS
|
||||
// (group-hover), and approximates placement with static absolute utility classes
|
||||
// instead of computed coordinates (so flip/shift and a numeric offset are gone —
|
||||
// the gap is a fixed ~8px via the m*-2 classes, matching the TSX default offset).
|
||||
|
||||
package webui
|
||||
|
||||
import "kjol/vdom"
|
||||
|
||||
const popoverCls = "bg-white rounded-default shadow-lg border border-neutral-200"
|
||||
|
||||
// popoverPlacementCls maps a floating placement to static absolute-position
|
||||
// utility classes relative to the Popover's `relative` wrapper.
|
||||
func popoverPlacementCls(placement string) string {
|
||||
switch placement {
|
||||
case "top", "top-start":
|
||||
return "bottom-full left-0 mb-2"
|
||||
case "top-end":
|
||||
return "bottom-full right-0 mb-2"
|
||||
case "bottom-end":
|
||||
return "top-full right-0 mt-2"
|
||||
case "left", "left-start":
|
||||
return "right-full top-0 mr-2"
|
||||
case "left-end":
|
||||
return "right-full bottom-0 mr-2"
|
||||
case "right", "right-start":
|
||||
return "left-full top-0 ml-2"
|
||||
case "right-end":
|
||||
return "left-full bottom-0 ml-2"
|
||||
default: // "bottom" / "bottom-start" and unknown
|
||||
return "top-full left-0 mt-2"
|
||||
}
|
||||
}
|
||||
|
||||
// PopoverProps configures Popover. Placement defaults to "bottom-start".
|
||||
type PopoverProps struct {
|
||||
Placement string
|
||||
Class string
|
||||
}
|
||||
|
||||
// Popover is the floating wrapper: a relative container that anchors an
|
||||
// absolutely-positioned PopoverContent to a PopoverTrigger. Compose a
|
||||
// PopoverTrigger and a PopoverContent as its children.
|
||||
func Popover(p PopoverProps, children ...*vdom.VNode) *vdom.VNode {
|
||||
return vdom.El("div", kids([]vdom.Mod{vdom.Attr("class", cx("relative inline-block", p.Class))}, children)...)
|
||||
}
|
||||
|
||||
// PopoverTriggerProps configures PopoverTrigger. Open drives aria-expanded and
|
||||
// OnToggle fires on click (the TSX toggles open on click).
|
||||
type PopoverTriggerProps struct {
|
||||
Open bool
|
||||
OnToggle func()
|
||||
Class string
|
||||
Title string
|
||||
}
|
||||
|
||||
// PopoverTrigger is the click target that opens/closes the popover.
|
||||
func PopoverTrigger(p PopoverTriggerProps, children ...*vdom.VNode) *vdom.VNode {
|
||||
expanded := "false"
|
||||
if p.Open {
|
||||
expanded = "true"
|
||||
}
|
||||
mods := []vdom.Mod{
|
||||
vdom.Attr("type", "button"),
|
||||
vdom.Attr("class", p.Class),
|
||||
vdom.Attr("aria-expanded", expanded),
|
||||
vdom.Attr("aria-haspopup", "menu"),
|
||||
}
|
||||
if p.Title != "" {
|
||||
mods = append(mods, vdom.Attr("title", p.Title))
|
||||
}
|
||||
if p.OnToggle != nil {
|
||||
mods = append(mods, vdom.On(vdom.EVENT_CLICK, p.OnToggle))
|
||||
}
|
||||
return vdom.El("button", kids(mods, children)...)
|
||||
}
|
||||
|
||||
// PopoverContentProps configures PopoverContent. Placement defaults to
|
||||
// "bottom-start"; Open toggles visibility (nil when closed).
|
||||
type PopoverContentProps struct {
|
||||
Open bool
|
||||
Placement string
|
||||
Class string
|
||||
}
|
||||
|
||||
// PopoverContent is the floating panel. It renders nil when Open is false.
|
||||
func PopoverContent(p PopoverContentProps, children ...*vdom.VNode) *vdom.VNode {
|
||||
if !p.Open {
|
||||
return nil
|
||||
}
|
||||
mods := []vdom.Mod{
|
||||
vdom.Attr("role", "menu"),
|
||||
vdom.Attr("data-floating-content", "true"),
|
||||
vdom.Attr("class", cx("absolute z-[110]", popoverPlacementCls(p.Placement), popoverCls, p.Class)),
|
||||
}
|
||||
return vdom.El("div", kids(mods, children)...)
|
||||
}
|
||||
|
||||
// HoverPopoverProps configures HoverPopover. Placement defaults to
|
||||
// "bottom-start".
|
||||
type HoverPopoverProps struct {
|
||||
Placement string
|
||||
Class string
|
||||
}
|
||||
|
||||
// HoverPopover is the hover-driven wrapper. It carries the Tailwind `group`
|
||||
// marker so HoverPopoverContent can reveal itself on hover purely in CSS.
|
||||
func HoverPopover(p HoverPopoverProps, children ...*vdom.VNode) *vdom.VNode {
|
||||
return vdom.El("div", kids([]vdom.Mod{vdom.Attr("class", cx("group relative inline-block", p.Class))}, children)...)
|
||||
}
|
||||
|
||||
// HoverPopoverTriggerProps configures HoverPopoverTrigger. OnMouseEnter /
|
||||
// OnMouseLeave mirror the TSX hover wiring (optional; hover reveal itself is CSS).
|
||||
type HoverPopoverTriggerProps struct {
|
||||
OnMouseEnter func()
|
||||
OnMouseLeave func()
|
||||
Class string
|
||||
}
|
||||
|
||||
// HoverPopoverTrigger is the hover target.
|
||||
func HoverPopoverTrigger(p HoverPopoverTriggerProps, children ...*vdom.VNode) *vdom.VNode {
|
||||
mods := []vdom.Mod{vdom.Attr("type", "button"), vdom.Attr("class", p.Class)}
|
||||
if p.OnMouseEnter != nil {
|
||||
mods = append(mods, vdom.On("mouseenter", p.OnMouseEnter))
|
||||
}
|
||||
if p.OnMouseLeave != nil {
|
||||
mods = append(mods, vdom.On("mouseleave", p.OnMouseLeave))
|
||||
}
|
||||
return vdom.El("button", kids(mods, children)...)
|
||||
}
|
||||
|
||||
// HoverPopoverContentProps configures HoverPopoverContent.
|
||||
type HoverPopoverContentProps struct {
|
||||
Placement string
|
||||
Class string
|
||||
OnMouseEnter func()
|
||||
OnMouseLeave func()
|
||||
}
|
||||
|
||||
// HoverPopoverContent is the hover panel: hidden by default and revealed while
|
||||
// the surrounding HoverPopover (group) is hovered.
|
||||
func HoverPopoverContent(p HoverPopoverContentProps, children ...*vdom.VNode) *vdom.VNode {
|
||||
const reveal = "invisible opacity-0 transition-opacity group-hover:visible group-hover:opacity-100"
|
||||
mods := []vdom.Mod{
|
||||
vdom.Attr("role", "menu"),
|
||||
vdom.Attr("data-floating-content", "true"),
|
||||
vdom.Attr("class", cx("absolute z-[110]", popoverPlacementCls(p.Placement), popoverCls, reveal, p.Class)),
|
||||
}
|
||||
if p.OnMouseEnter != nil {
|
||||
mods = append(mods, vdom.On("mouseenter", p.OnMouseEnter))
|
||||
}
|
||||
if p.OnMouseLeave != nil {
|
||||
mods = append(mods, vdom.On("mouseleave", p.OnMouseLeave))
|
||||
}
|
||||
return vdom.El("div", kids(mods, children)...)
|
||||
}
|
||||
194
go/webui/prettytable.go
Normal file
194
go/webui/prettytable.go
Normal file
@@ -0,0 +1,194 @@
|
||||
package webui
|
||||
|
||||
import "kjol/vdom"
|
||||
|
||||
// Port of web/kit/PrettyTable.tsx.
|
||||
//
|
||||
// PrettyTable renders only the table chrome: the container/wrapper, the styled
|
||||
// <thead>, and an empty-styled <tbody> into which the CALLER passes body rows as
|
||||
// children. That matches the TSX, whose body is `props.children` (rows built with
|
||||
// AutoTable's TdLeft/TdRight/TdCenter cells). Those cell helpers live in AutoTable
|
||||
// (not ported here), so callers build rows with the vdom tag builders directly,
|
||||
// e.g. vdom.El("tr", vdom.El("td", vdom.Attr("class","text-right"), vdom.Text(...))).
|
||||
// (CellGrid is the component that models columns + a per-cell Render func.)
|
||||
//
|
||||
// The enum values, class maps, and shared class strings that PrettyTable.tsx
|
||||
// imports from AutoTable.tsx are inlined here (prefixed pt*/PrettyTable*) so this
|
||||
// file compiles on its own and never clashes with a parallel AutoTable port.
|
||||
|
||||
// PrettyTableColumnPosition controls header + cell alignment.
|
||||
type PrettyTableColumnPosition int
|
||||
|
||||
const (
|
||||
PrettyTableColLeft PrettyTableColumnPosition = 0
|
||||
PrettyTableColRight PrettyTableColumnPosition = 1
|
||||
PrettyTableColCenter PrettyTableColumnPosition = 2
|
||||
)
|
||||
|
||||
// PrettyTableHeaderColor selects the header background/text palette.
|
||||
type PrettyTableHeaderColor int
|
||||
|
||||
const (
|
||||
PrettyTableColorDefault PrettyTableHeaderColor = 0
|
||||
PrettyTableColorBlue PrettyTableHeaderColor = 1
|
||||
PrettyTableColorGreen PrettyTableHeaderColor = 2
|
||||
PrettyTableColorGray PrettyTableHeaderColor = 3
|
||||
PrettyTableColorDarkBlue PrettyTableHeaderColor = 4
|
||||
)
|
||||
|
||||
// PrettyTableSize selects header/body padding density.
|
||||
type PrettyTableSize int
|
||||
|
||||
const (
|
||||
PrettyTableSizeDefault PrettyTableSize = 0
|
||||
PrettyTableSizeCompact PrettyTableSize = 1
|
||||
PrettyTableSizeSuperCompact PrettyTableSize = 2
|
||||
)
|
||||
|
||||
const ptTblContainer = "relative flex flex-col w-full h-full bg-white 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",
|
||||
PrettyTableColorBlue: "bg-sky-700 text-white",
|
||||
PrettyTableColorGreen: "bg-green-700 text-white",
|
||||
PrettyTableColorGray: "bg-neutral-600 text-white",
|
||||
PrettyTableColorDarkBlue: "bg-sky-900 text-white",
|
||||
}
|
||||
|
||||
var ptHeaderTextCls = map[PrettyTableHeaderColor]string{
|
||||
PrettyTableColorDefault: "font-bold uppercase tracking-wider",
|
||||
PrettyTableColorBlue: "font-semibold",
|
||||
PrettyTableColorGreen: "font-semibold",
|
||||
PrettyTableColorGray: "font-semibold",
|
||||
PrettyTableColorDarkBlue: "font-semibold",
|
||||
}
|
||||
|
||||
// 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",
|
||||
}
|
||||
|
||||
var ptHeaderPaddingCls = map[PrettyTableSize]string{
|
||||
PrettyTableSizeDefault: "p-4 text-sm",
|
||||
PrettyTableSizeCompact: "py-1.5 px-2 text-sm",
|
||||
PrettyTableSizeSuperCompact: "py-1 px-2 text-xs",
|
||||
}
|
||||
|
||||
var ptBodyPaddingCls = map[PrettyTableSize]string{
|
||||
PrettyTableSizeDefault: "text-sm [&_td]:p-4",
|
||||
PrettyTableSizeCompact: "text-sm [&_td]:py-1 [&_td]:px-2",
|
||||
PrettyTableSizeSuperCompact: "text-xs [&_td]:py-0.5 [&_td]:px-2",
|
||||
}
|
||||
|
||||
var ptPosCls = map[PrettyTableColumnPosition]string{
|
||||
PrettyTableColLeft: "text-left",
|
||||
PrettyTableColRight: "text-right",
|
||||
PrettyTableColCenter: "text-center",
|
||||
}
|
||||
|
||||
var ptHeaderInnerPos = map[PrettyTableColumnPosition]string{
|
||||
PrettyTableColLeft: "",
|
||||
PrettyTableColRight: "flex-row-reverse",
|
||||
PrettyTableColCenter: "justify-center",
|
||||
}
|
||||
|
||||
// PrettyTableColumn is one header column definition.
|
||||
type PrettyTableColumn struct {
|
||||
DisplayName string
|
||||
DisplayPosition PrettyTableColumnPosition
|
||||
HeaderClasses string
|
||||
}
|
||||
|
||||
// PrettyTableOptions configures the table chrome. The Go zero value matches the
|
||||
// TSX defaults exactly (size default, all flags off, default color, left align),
|
||||
// so an unset PrettyTableOptions{} needs no merge.
|
||||
type PrettyTableOptions struct {
|
||||
Size PrettyTableSize
|
||||
Shadow bool
|
||||
Hover bool
|
||||
Alternate bool
|
||||
HeaderBorderY bool
|
||||
SurroundingBorder bool
|
||||
BorderX bool
|
||||
BorderY bool
|
||||
Color PrettyTableHeaderColor
|
||||
TableLayoutAuto bool
|
||||
}
|
||||
|
||||
func prettyTableBodyClass(o PrettyTableOptions) string {
|
||||
c := ptBodyPaddingCls[o.Size]
|
||||
if o.BorderY {
|
||||
c = cx(c, "[&_td+td]:border-l [&_td+td]:border-neutral-300")
|
||||
}
|
||||
if o.Alternate {
|
||||
c = cx(c, "[&_tr:nth-child(even)]:bg-neutral-100")
|
||||
}
|
||||
if o.BorderX {
|
||||
c = cx(c, "[&_tr:not(:last-child)]:border-b [&_tr:not(:last-child)]:border-neutral-300")
|
||||
}
|
||||
if o.Hover {
|
||||
c = cx(c, ptRowHoverCls[o.Color])
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// PrettyTable renders the table container + styled header, with body rows passed
|
||||
// as children (see the file comment). opts may be the zero value for defaults.
|
||||
func PrettyTable(columns []PrettyTableColumn, opts PrettyTableOptions, children ...*vdom.VNode) *vdom.VNode {
|
||||
containerCls := ptTblContainer
|
||||
if opts.SurroundingBorder {
|
||||
containerCls = cx(containerCls, "border border-neutral-300")
|
||||
}
|
||||
if opts.Shadow {
|
||||
containerCls = cx(containerCls, "shadow-sm")
|
||||
}
|
||||
|
||||
tableCls := ptTblBase
|
||||
if !opts.TableLayoutAuto {
|
||||
tableCls = cx(tableCls, "table-fixed")
|
||||
}
|
||||
|
||||
var headerCells []*vdom.VNode
|
||||
for displayIdx, col := range columns {
|
||||
pos := col.DisplayPosition
|
||||
thCls := cx(ptHeaderPaddingCls[opts.Size], ptHeaderColorCls[opts.Color], ptPosCls[pos])
|
||||
if opts.HeaderBorderY && displayIdx > 0 {
|
||||
thCls = cx(thCls, "border-l border-l-neutral-300")
|
||||
}
|
||||
thCls = cx(thCls, col.HeaderClasses)
|
||||
|
||||
innerCls := cx(ptHeaderInnerBase, ptHeaderInnerPos[pos])
|
||||
headerCells = append(headerCells, vdom.El("th",
|
||||
vdom.Attr("class", thCls),
|
||||
vdom.El("div", vdom.Attr("class", ptHeaderContent),
|
||||
vdom.El("div", vdom.Attr("class", innerCls),
|
||||
vdom.El("div", vdom.Attr("class", cx("grow text-sm", ptHeaderTextCls[opts.Color])),
|
||||
vdom.Text(col.DisplayName),
|
||||
),
|
||||
),
|
||||
),
|
||||
))
|
||||
}
|
||||
|
||||
thead := vdom.El("thead",
|
||||
vdom.Attr("class", "[&_th]:border-b [&_th]:border-neutral-300"),
|
||||
vdom.El("tr", kids(nil, headerCells)...),
|
||||
)
|
||||
|
||||
tbody := vdom.El("tbody", kids([]vdom.Mod{vdom.Attr("class", prettyTableBodyClass(opts))}, children)...)
|
||||
|
||||
return vdom.El("div", vdom.Attr("class", containerCls),
|
||||
vdom.El("div", vdom.Attr("class", ptTblWrapper),
|
||||
vdom.El("table", vdom.Attr("class", tableCls), thead, tbody),
|
||||
),
|
||||
)
|
||||
}
|
||||
52
go/webui/remoteupdateflash.go
Normal file
52
go/webui/remoteupdateflash.go
Normal file
@@ -0,0 +1,52 @@
|
||||
// Port of web/kit/RemoteUpdateFlash.tsx.
|
||||
|
||||
package webui
|
||||
|
||||
import "kjol/vdom"
|
||||
|
||||
// RemoteFlash is the trigger/signal pair from createRemoteFlash: Fire() shows
|
||||
// the flash, Visible() reports whether it is currently shown, and Clear() hides
|
||||
// it. Create one with NewRemoteFlash and render RemoteUpdateFlash(flash.Visible())
|
||||
// wherever the pill should appear.
|
||||
type RemoteFlash struct {
|
||||
visible *vdom.Signal[bool]
|
||||
}
|
||||
|
||||
// NewRemoteFlash creates a flash controller.
|
||||
//
|
||||
// NOTE: the TSX createRemoteFlash auto-clears after durationMs via setTimeout.
|
||||
// The neutral runtime has no timer, so the auto-clear is dropped — the caller
|
||||
// must call Clear() when the flash should end (durationMs is retained only for
|
||||
// API/documentation parity).
|
||||
func NewRemoteFlash(durationMs int) *RemoteFlash {
|
||||
_ = durationMs
|
||||
return &RemoteFlash{visible: vdom.NewSignal(false)}
|
||||
}
|
||||
|
||||
// Visible reports whether the flash is currently showing.
|
||||
func (f *RemoteFlash) Visible() bool { return f.visible.Get() }
|
||||
|
||||
// Fire shows the flash (call when a remote update arrives).
|
||||
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"
|
||||
|
||||
// RemoteUpdateFlash is a small pill that briefly shows "Updated". It renders
|
||||
// nothing (nil) when when is false, mirroring the TSX <Show when=…>.
|
||||
func RemoteUpdateFlash(when bool) *vdom.VNode {
|
||||
if !when {
|
||||
return nil
|
||||
}
|
||||
return vdom.El("div",
|
||||
vdom.Attr("class", remoteUpdateFlashCls),
|
||||
vdom.El("svg",
|
||||
vdom.Attr("viewBox", "0 0 12 12"),
|
||||
vdom.Attr("class", "w-2.5 h-2.5 fill-current"),
|
||||
vdom.Raw(`<circle cx="6" cy="6" r="6"/>`),
|
||||
),
|
||||
vdom.Text("Updated"),
|
||||
)
|
||||
}
|
||||
131
go/webui/sidebar.go
Normal file
131
go/webui/sidebar.go
Normal file
@@ -0,0 +1,131 @@
|
||||
// Port of web/kit/Sidebar.tsx.
|
||||
|
||||
package webui
|
||||
|
||||
import "kjol/vdom"
|
||||
|
||||
const sidebarNavRoot = "bg-white rounded-default shadow-sm border border-neutral-200 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 sidebarNavIcon = "mr-2"
|
||||
|
||||
const sidebarLayoutRoot = "grid grid-cols-1 gap-8 min-h-screen items-start lg:grid-cols-12"
|
||||
const sidebarLayoutSidebar = "hidden lg:block lg:col-span-2 lg:self-start lg:h-full"
|
||||
const sidebarLayoutSticky = "sticky top-20 max-h-[calc(100vh_-_7rem)] overflow-y-auto"
|
||||
const sidebarLayoutStickyFull = "sticky top-0 h-screen overflow-y-auto"
|
||||
|
||||
// sidebarLayoutMain: lg:pr-8 mirrors the grid's gap-8 so the content has matching
|
||||
// breathing room on the right instead of hugging the viewport edge.
|
||||
const sidebarLayoutMain = "col-span-1 min-w-0 lg:col-span-10 lg:pr-8"
|
||||
|
||||
// SidebarNavItem is one entry in a SidebarNav. Icon is an optional leading node
|
||||
// (e.g. webui.Icon(...)). Children are optional second-level items that jump to
|
||||
// sub-sections within this item.
|
||||
type SidebarNavItem struct {
|
||||
ID string
|
||||
Label string
|
||||
Icon *vdom.VNode
|
||||
Children []SidebarNavItem
|
||||
}
|
||||
|
||||
// SidebarNav renders a vertical nav of jump links. onItemClick receives the id
|
||||
// of the clicked item (top-level or sub-item).
|
||||
//
|
||||
// NOTE: the TSX handler also does document.getElementById(id).scrollIntoView({
|
||||
// behavior: "smooth" }) to smooth-scroll to the target section. That is
|
||||
// browser-only (no document in the neutral runtime) and is dropped here; perform
|
||||
// the scroll from within the app's onItemClick if needed.
|
||||
func SidebarNav(items []SidebarNavItem, onItemClick func(string), class string) *vdom.VNode {
|
||||
handleClick := func(id string) {
|
||||
if onItemClick != nil {
|
||||
onItemClick(id)
|
||||
}
|
||||
}
|
||||
|
||||
listMods := []vdom.Mod{vdom.Attr("class", sidebarNavList)}
|
||||
for _, item := range items {
|
||||
id := item.ID
|
||||
btnMods := []vdom.Mod{
|
||||
vdom.Attr("class", sidebarNavBtn),
|
||||
vdom.On(vdom.EVENT_CLICK, func() { handleClick(id) }),
|
||||
}
|
||||
if item.Icon != nil {
|
||||
btnMods = append(btnMods, vdom.El("span", vdom.Attr("class", sidebarNavIcon), item.Icon))
|
||||
}
|
||||
btnMods = append(btnMods, vdom.Text(item.Label))
|
||||
|
||||
liMods := []vdom.Mod{vdom.El("button", btnMods...)}
|
||||
|
||||
if len(item.Children) > 0 {
|
||||
subListMods := []vdom.Mod{vdom.Attr("class", sidebarNavList)}
|
||||
for _, sub := range item.Children {
|
||||
sid := sub.ID
|
||||
subBtnMods := []vdom.Mod{
|
||||
vdom.Attr("class", sidebarNavSubBtn),
|
||||
vdom.On(vdom.EVENT_CLICK, func() { handleClick(sid) }),
|
||||
}
|
||||
if sub.Icon != nil {
|
||||
subBtnMods = append(subBtnMods, vdom.El("span", vdom.Attr("class", sidebarNavIcon), sub.Icon))
|
||||
}
|
||||
subBtnMods = append(subBtnMods, vdom.Text(sub.Label))
|
||||
subListMods = append(subListMods, vdom.El("li", vdom.El("button", subBtnMods...)))
|
||||
}
|
||||
liMods = append(liMods, vdom.El("ul", subListMods...))
|
||||
}
|
||||
|
||||
listMods = append(listMods, vdom.El("li", liMods...))
|
||||
}
|
||||
|
||||
return vdom.El("nav",
|
||||
vdom.Attr("class", cx(sidebarNavRoot, class)),
|
||||
vdom.El("ul", listMods...),
|
||||
)
|
||||
}
|
||||
|
||||
// SidebarLayoutProps configures SidebarLayout. Sidebar is the aside content;
|
||||
// children (variadic on SidebarLayout) are the main content.
|
||||
type SidebarLayoutProps struct {
|
||||
Sidebar *vdom.VNode
|
||||
Class string
|
||||
FullHeight bool
|
||||
Collapsible bool
|
||||
OnToggleCollapse func()
|
||||
}
|
||||
|
||||
// SidebarLayout is a two-column responsive grid: a sticky sidebar (aside) and a
|
||||
// main content column. Children are the main content.
|
||||
//
|
||||
// NOTE: the TSX keeps an internal `collapsed` signal toggled by the collapse
|
||||
// button, but nothing in its markup reads it — so collapsing has no visual
|
||||
// effect there. This port renders the toggle button when Collapsible is set and
|
||||
// wires OnToggleCollapse; the app owns any collapsed state/behavior.
|
||||
func SidebarLayout(p SidebarLayoutProps, children ...*vdom.VNode) *vdom.VNode {
|
||||
sticky := sidebarLayoutSticky
|
||||
if p.FullHeight {
|
||||
sticky = sidebarLayoutStickyFull
|
||||
}
|
||||
|
||||
asideMods := []vdom.Mod{
|
||||
vdom.Attr("class", sidebarLayoutSidebar),
|
||||
vdom.El("div", kids([]vdom.Mod{vdom.Attr("class", sticky)}, []*vdom.VNode{p.Sidebar})...),
|
||||
}
|
||||
if p.Collapsible {
|
||||
btnMods := []vdom.Mod{
|
||||
vdom.Attr("type", "button"),
|
||||
vdom.Attr("aria-label", "Toggle sidebar"),
|
||||
}
|
||||
if p.OnToggleCollapse != nil {
|
||||
btnMods = append(btnMods, vdom.On(vdom.EVENT_CLICK, p.OnToggleCollapse))
|
||||
}
|
||||
asideMods = append(asideMods, vdom.El("button", btnMods...))
|
||||
}
|
||||
|
||||
main := vdom.El("main", kids([]vdom.Mod{vdom.Attr("class", sidebarLayoutMain)}, children)...)
|
||||
|
||||
return vdom.El("div",
|
||||
vdom.Attr("class", cx(sidebarLayoutRoot, p.Class)),
|
||||
vdom.El("aside", asideMods...),
|
||||
main,
|
||||
)
|
||||
}
|
||||
147
go/webui/tabs.go
Normal file
147
go/webui/tabs.go
Normal file
@@ -0,0 +1,147 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"kjol/vdom"
|
||||
)
|
||||
|
||||
// Port of web/kit/Tabs.tsx.
|
||||
//
|
||||
// NOTE: The TSX persisted the active index in localStorage (storageKey) and
|
||||
// synced sibling tab groups via synthetic "storage" events on mount. Those are
|
||||
// browser-only side effects with no neutral-runtime equivalent, so they are
|
||||
// dropped: selection collapses to a plain ActiveIndex value plus an OnTabChange
|
||||
// callback (the caller owns the state, per the porting guide). The TSX
|
||||
// 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"
|
||||
|
||||
// TabItem is one tab: a title, an optional numeric badge (shown only when > 0),
|
||||
// and optional inline panel content.
|
||||
type TabItem struct {
|
||||
Title string
|
||||
Badge int
|
||||
Content *vdom.VNode
|
||||
}
|
||||
|
||||
// TabGroupProps configures TabGroup. ActiveIndex is the selected tab; clicking a
|
||||
// tab calls OnTabChange with its index (the caller updates ActiveIndex).
|
||||
type TabGroupProps struct {
|
||||
Items []TabItem
|
||||
ActiveIndex int
|
||||
OnTabChange func(int)
|
||||
// Actions is optional content rendered on the right side of the tab bar.
|
||||
Actions *vdom.VNode
|
||||
// Class adds extra classes on the root (e.g. "ui-tabs" for structured panel
|
||||
// CSS, "page-tabs" for tighter spacing).
|
||||
Class string
|
||||
// Fill makes tabs fill available height with internally-scrolling panels.
|
||||
Fill bool
|
||||
// Stretch is tri-state (nil = unset): when Actions is nil it defaults to
|
||||
// true, otherwise it defaults to false — matching the TSX prop semantics.
|
||||
Stretch *bool
|
||||
PageTabs bool
|
||||
}
|
||||
|
||||
// TabGroup renders a horizontal tab bar with optional inline panels.
|
||||
func TabGroup(p TabGroupProps) *vdom.VNode {
|
||||
structured := p.Fill || strings.Contains(p.Class, "ui-tabs")
|
||||
pageTabs := p.PageTabs || strings.Contains(p.Class, "page-tabs")
|
||||
|
||||
var rootCls string
|
||||
switch {
|
||||
case p.Fill:
|
||||
rootCls = cx("ui-tabs flex w-full min-h-0 flex-1 flex-col overflow-hidden", p.Class)
|
||||
case pageTabs:
|
||||
rootCls = cx("w-full page-tabs", p.Class)
|
||||
default:
|
||||
rootCls = cx("w-full pb-4", p.Class)
|
||||
}
|
||||
|
||||
headerCls := "overflow-x-auto flex flex-row w-full text-sm"
|
||||
if structured {
|
||||
headerCls = "header overflow-x-auto flex flex-row w-full shrink-0 text-sm"
|
||||
}
|
||||
|
||||
stretchTabs := false
|
||||
if p.Actions != nil {
|
||||
stretchTabs = p.Stretch != nil && *p.Stretch
|
||||
} else {
|
||||
stretchTabs = p.Stretch == nil || *p.Stretch
|
||||
}
|
||||
stretchCls := ""
|
||||
if stretchTabs {
|
||||
stretchCls = "flex-1 justify-center md:flex-initial md:justify-start"
|
||||
}
|
||||
|
||||
panelCls := func(index int) string {
|
||||
active := index == p.ActiveIndex
|
||||
if !structured {
|
||||
if active {
|
||||
return ""
|
||||
}
|
||||
return "hidden"
|
||||
}
|
||||
if !active {
|
||||
return "panel hidden"
|
||||
}
|
||||
if p.Fill {
|
||||
return "panel flex min-h-0 flex-1 flex-col overflow-hidden"
|
||||
}
|
||||
return "panel"
|
||||
}
|
||||
|
||||
header := []vdom.Mod{vdom.Attr("class", headerCls)}
|
||||
for i, item := range p.Items {
|
||||
idx := i
|
||||
state := tabsInactive
|
||||
if i == p.ActiveIndex {
|
||||
state = tabsActive
|
||||
}
|
||||
btn := []vdom.Mod{
|
||||
vdom.Attr("type", "button"),
|
||||
vdom.Attr("class", cx(tabsBase, stretchCls, state)),
|
||||
vdom.On(vdom.EVENT_CLICK, func() {
|
||||
if p.OnTabChange != nil {
|
||||
p.OnTabChange(idx)
|
||||
}
|
||||
}),
|
||||
vdom.Text(item.Title),
|
||||
}
|
||||
if item.Badge > 0 {
|
||||
btn = append(btn, vdom.El("span",
|
||||
vdom.Attr("class", "inline-flex items-center justify-center min-w-5 h-5 px-1 text-xs font-semibold bg-primary text-white rounded-full"),
|
||||
vdom.Text(strconv.Itoa(item.Badge))))
|
||||
}
|
||||
header = append(header, vdom.El("button", btn...))
|
||||
}
|
||||
if p.Actions != nil {
|
||||
header = append(header, vdom.El("div",
|
||||
vdom.Attr("class", "tab-actions flex-1 self-end border-b-2 border-neutral-200 flex items-center justify-end pb-1"),
|
||||
vdom.El("div", vdom.Attr("class", "flex items-center min-w-0"), p.Actions),
|
||||
))
|
||||
}
|
||||
|
||||
root := []vdom.Mod{vdom.Attr("class", rootCls), vdom.El("div", header...)}
|
||||
|
||||
hasInlinePanels := false
|
||||
for _, item := range p.Items {
|
||||
if item.Content != nil {
|
||||
hasInlinePanels = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if hasInlinePanels {
|
||||
for i, item := range p.Items {
|
||||
if item.Content == nil {
|
||||
continue
|
||||
}
|
||||
root = append(root, vdom.El("div", vdom.Attr("class", panelCls(i)), item.Content))
|
||||
}
|
||||
}
|
||||
return vdom.El("div", root...)
|
||||
}
|
||||
171
go/webui/toast.go
Normal file
171
go/webui/toast.go
Normal file
@@ -0,0 +1,171 @@
|
||||
package webui
|
||||
|
||||
import "kjol/vdom"
|
||||
|
||||
// Port of web/kit/Toast.tsx.
|
||||
//
|
||||
// NOTE: Solid's context API (useToast + addToast/success/error/warning/info/
|
||||
// generic) is dropped — there is no context or portal in the neutral runtime.
|
||||
// Callers own the []Toast list and its removal instead: build the toasts, pass
|
||||
// them to ToastProvider, and handle OnDismiss to drop one by ID. The
|
||||
// auto-generated toast IDs (generateId) become the caller's responsibility.
|
||||
// NOTE: auto-dismiss timers, the requestAnimationFrame progress countdown, and
|
||||
// the exit (fade/slide) animation are dropped (no timers/rAF here). The progress
|
||||
// bar, when shown, renders full-width and static; the dismiss button removes the
|
||||
// toast immediately.
|
||||
// NOTE: the "circle-xmark" and "circle-info" icons are not in the default icon
|
||||
// registry and render as empty boxes until an app registers them.
|
||||
|
||||
// ToastType selects a toast's accent color and leading icon.
|
||||
type ToastType string
|
||||
|
||||
const (
|
||||
ToastSuccess ToastType = "success"
|
||||
ToastError ToastType = "error"
|
||||
ToastWarning ToastType = "warning"
|
||||
ToastInfo ToastType = "info"
|
||||
ToastGeneric ToastType = "generic"
|
||||
)
|
||||
|
||||
// ToastPosition is where the container is anchored on screen.
|
||||
type ToastPosition string
|
||||
|
||||
const (
|
||||
ToastTopRight ToastPosition = "top-right"
|
||||
ToastTopLeft ToastPosition = "top-left"
|
||||
ToastBottomRight ToastPosition = "bottom-right"
|
||||
ToastBottomLeft ToastPosition = "bottom-left"
|
||||
ToastTopCenter ToastPosition = "top-center"
|
||||
ToastBottomCenter ToastPosition = "bottom-center"
|
||||
)
|
||||
|
||||
// Toast is one notification. Duration is in ms (0 = no auto-dismiss and no
|
||||
// progress bar). Dismissible shows the close button; ShowProgress shows the
|
||||
// (static) progress bar when Duration > 0. Type defaults to ToastInfo.
|
||||
type Toast struct {
|
||||
ID string
|
||||
Message string
|
||||
Type ToastType
|
||||
Duration int
|
||||
Dismissible bool
|
||||
ShowProgress bool
|
||||
}
|
||||
|
||||
var toastTypeIcons = map[ToastType]string{
|
||||
ToastSuccess: "circle-check",
|
||||
ToastError: "circle-xmark",
|
||||
ToastWarning: "triangle-exclamation",
|
||||
ToastInfo: "circle-info",
|
||||
ToastGeneric: "",
|
||||
}
|
||||
|
||||
const toastContainerBase = "fixed z-[200] flex flex-col gap-2"
|
||||
|
||||
var toastContainerPositions = map[ToastPosition]string{
|
||||
ToastTopRight: "top-4 right-4",
|
||||
ToastTopLeft: "top-4 left-4",
|
||||
ToastBottomRight: "bottom-4 right-4 flex-col-reverse",
|
||||
ToastBottomLeft: "bottom-4 left-4 flex-col-reverse",
|
||||
ToastTopCenter: "top-4 left-1/2 -translate-x-1/2",
|
||||
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"
|
||||
|
||||
var toastTypeBorder = map[ToastType]string{
|
||||
ToastSuccess: "border-l-green-700",
|
||||
ToastError: "border-l-red-700",
|
||||
ToastWarning: "border-l-yellow-500",
|
||||
ToastInfo: "border-l-sky-800",
|
||||
ToastGeneric: "border-l-neutral-400",
|
||||
}
|
||||
|
||||
var toastIconColor = map[ToastType]string{
|
||||
ToastSuccess: "text-green-600",
|
||||
ToastError: "text-red-600",
|
||||
ToastWarning: "text-yellow-600",
|
||||
ToastInfo: "text-sky-700",
|
||||
ToastGeneric: "",
|
||||
}
|
||||
|
||||
// ToastItem renders a single toast. onDismiss receives the toast's ID when the
|
||||
// close button is pressed.
|
||||
func ToastItem(t Toast, onDismiss func(string)) *vdom.VNode {
|
||||
typ := t.Type
|
||||
if typ == "" {
|
||||
typ = ToastInfo
|
||||
}
|
||||
icon := toastTypeIcons[typ]
|
||||
showProgress := t.ShowProgress && t.Duration > 0
|
||||
|
||||
row := []vdom.Mod{vdom.Attr("class", "flex items-start gap-3 p-4")}
|
||||
if icon != "" {
|
||||
row = append(row, Icon(icon, 20, cx("shrink-0 mt-0.5", toastIconColor[typ])))
|
||||
}
|
||||
row = append(row, vdom.El("div", vdom.Attr("class", "flex-1 text-sm text-neutral-800"), 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("aria-label", "Dismiss"),
|
||||
}
|
||||
if onDismiss != nil {
|
||||
id := t.ID
|
||||
dismiss = append(dismiss, vdom.On(vdom.EVENT_CLICK, func() { onDismiss(id) }))
|
||||
}
|
||||
dismiss = append(dismiss, Icon("xmark", 16, ""))
|
||||
row = append(row, vdom.El("button", dismiss...))
|
||||
}
|
||||
|
||||
mods := []vdom.Mod{
|
||||
vdom.Attr("class", cx(toastBase, toastTypeBorder[typ])),
|
||||
vdom.Attr("role", "alert"),
|
||||
vdom.El("div", row...),
|
||||
}
|
||||
if showProgress {
|
||||
mods = append(mods, vdom.El("div", vdom.Attr("class", "h-1 w-full bg-neutral-100"),
|
||||
vdom.El("div", vdom.Attr("class", "h-full bg-neutral-300"), vdom.Attr("style", "width:100%")),
|
||||
))
|
||||
}
|
||||
return vdom.El("div", mods...)
|
||||
}
|
||||
|
||||
// ToastProviderProps configures ToastProvider. Position defaults to
|
||||
// ToastBottomRight; MaxToasts defaults to 5 (only the most recent are kept).
|
||||
type ToastProviderProps struct {
|
||||
Position ToastPosition
|
||||
MaxToasts int
|
||||
Toasts []Toast
|
||||
OnDismiss func(string)
|
||||
}
|
||||
|
||||
// ToastProvider renders its children followed by the fixed toast container. It
|
||||
// replaces the TSX ToastContext.Provider; the wrapper uses display:contents so
|
||||
// it introduces no layout box of its own.
|
||||
func ToastProvider(p ToastProviderProps, children ...*vdom.VNode) *vdom.VNode {
|
||||
position := p.Position
|
||||
if position == "" {
|
||||
position = ToastBottomRight
|
||||
}
|
||||
max := p.MaxToasts
|
||||
if max <= 0 {
|
||||
max = 5
|
||||
}
|
||||
toasts := p.Toasts
|
||||
if len(toasts) > max {
|
||||
toasts = toasts[len(toasts)-max:]
|
||||
}
|
||||
|
||||
container := []vdom.Mod{
|
||||
vdom.Attr("class", cx(toastContainerBase, toastContainerPositions[position])),
|
||||
vdom.Attr("aria-live", "polite"),
|
||||
vdom.Attr("aria-label", "Notifications"),
|
||||
}
|
||||
for _, t := range toasts {
|
||||
container = append(container, ToastItem(t, p.OnDismiss))
|
||||
}
|
||||
|
||||
mods := []vdom.Mod{vdom.Attr("class", "contents")}
|
||||
mods = kids(mods, children)
|
||||
mods = append(mods, vdom.El("div", container...))
|
||||
return vdom.El("div", mods...)
|
||||
}
|
||||
65
go/webui/toggleswitch.go
Normal file
65
go/webui/toggleswitch.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package webui
|
||||
|
||||
import "kjol/vdom"
|
||||
|
||||
// Port of web/kit/ToggleSwitch.tsx. Reactive accessors collapse to plain values.
|
||||
|
||||
// ToggleSwitch renders an on/off <button role="switch"> with an optional label
|
||||
// and description. onChange receives the next checked state.
|
||||
func ToggleSwitch(checked bool, onChange func(bool), label, description string, disabled bool, class string) *vdom.VNode {
|
||||
toggle := func() {
|
||||
if disabled {
|
||||
return
|
||||
}
|
||||
onChange(!checked)
|
||||
}
|
||||
|
||||
trackState := "bg-neutral-300"
|
||||
if checked {
|
||||
trackState = "bg-primary"
|
||||
}
|
||||
trackCls := cx("relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors focus-visible:outline-2 focus-visible:outline-primary focus-visible:outline-offset-2 disabled:cursor-not-allowed disabled:opacity-50", trackState)
|
||||
|
||||
knobState := "translate-x-0.5"
|
||||
if checked {
|
||||
knobState = "translate-x-[18px]"
|
||||
}
|
||||
knobCls := cx("inline-block h-4 w-4 transform rounded-full bg-white shadow-sm transition-transform", knobState)
|
||||
|
||||
ariaChecked := "false"
|
||||
if checked {
|
||||
ariaChecked = "true"
|
||||
}
|
||||
btnMods := []vdom.Mod{
|
||||
vdom.Attr("type", "button"),
|
||||
vdom.Attr("role", "switch"),
|
||||
vdom.Attr("aria-checked", ariaChecked),
|
||||
vdom.Attr("class", trackCls),
|
||||
vdom.On(vdom.EVENT_CLICK, toggle),
|
||||
vdom.El("span", vdom.Attr("class", knobCls)),
|
||||
}
|
||||
if disabled {
|
||||
btnMods = append(btnMods, vdom.Attr("disabled", "disabled"))
|
||||
}
|
||||
|
||||
mods := []vdom.Mod{vdom.Attr("class", cx("flex items-center gap-2", class)), vdom.El("button", btnMods...)}
|
||||
|
||||
if label != "" || description != "" {
|
||||
text := []vdom.Mod{vdom.Attr("class", "flex flex-col leading-tight")}
|
||||
if label != "" {
|
||||
labelColor := "text-neutral-800"
|
||||
if disabled {
|
||||
labelColor = "text-neutral-400"
|
||||
}
|
||||
text = append(text, vdom.El("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.El("span", vdom.Attr("class", "text-xs text-neutral-500"), vdom.Text(description)))
|
||||
}
|
||||
mods = append(mods, vdom.El("div", text...))
|
||||
}
|
||||
return vdom.El("div", mods...)
|
||||
}
|
||||
101
go/webui/tooltips.go
Normal file
101
go/webui/tooltips.go
Normal file
@@ -0,0 +1,101 @@
|
||||
// Port of web/kit/Tooltips.tsx.
|
||||
//
|
||||
// NOTE: the TSX builds tooltips on Floating.tsx (floating-ui-style measured
|
||||
// positioning with flip/shift, a Portal, a FloatingContext, and hover/focus
|
||||
// open-delay timers). The neutral runtime has none of that, so this port keeps
|
||||
// the API + Tailwind + arrow markup and instead reveals the bubble purely in CSS
|
||||
// via the Tailwind `group` marker (group-hover / group-focus-within). Placement
|
||||
// is approximated with static absolute utility classes (no flip/shift and no
|
||||
// measured coordinates), the numeric offset collapses to the fixed ~8px m*-2 gap
|
||||
// (the TSX default), and the open delay is dropped.
|
||||
|
||||
package webui
|
||||
|
||||
import "kjol/vdom"
|
||||
|
||||
const tooltipCls = "bg-neutral-800 text-white text-sm px-2.5 py-1.5 rounded-default shadow-lg max-w-80 relative"
|
||||
|
||||
// tooltipArrowCls returns the arrow classes for a tooltip on the given base side
|
||||
// (the arrow points back toward the trigger). Ported verbatim from arrowCls.
|
||||
func tooltipArrowCls(base string) string {
|
||||
const common = "absolute w-0 h-0"
|
||||
switch base {
|
||||
case "top":
|
||||
return common + " -bottom-[6px] left-1/2 -translate-x-1/2 border-l-[6px] border-l-transparent border-r-[6px] border-r-transparent border-t-[6px] border-t-neutral-800"
|
||||
case "bottom":
|
||||
return common + " -top-[6px] left-1/2 -translate-x-1/2 border-l-[6px] border-l-transparent border-r-[6px] border-r-transparent border-b-[6px] border-b-neutral-800"
|
||||
case "left":
|
||||
return common + " -right-[6px] top-1/2 -translate-y-1/2 border-t-[6px] border-t-transparent border-b-[6px] border-b-transparent border-l-[6px] border-l-neutral-800"
|
||||
case "right":
|
||||
return common + " -left-[6px] top-1/2 -translate-y-1/2 border-t-[6px] border-t-transparent border-b-[6px] border-b-transparent border-r-[6px] border-r-neutral-800"
|
||||
default:
|
||||
return common
|
||||
}
|
||||
}
|
||||
|
||||
// tooltipPlacementCls positions the bubble relative to the trigger wrapper.
|
||||
func tooltipPlacementCls(placement string) string {
|
||||
switch placement {
|
||||
case "bottom":
|
||||
return "top-full left-1/2 -translate-x-1/2 mt-2"
|
||||
case "left":
|
||||
return "right-full top-1/2 -translate-y-1/2 mr-2"
|
||||
case "right":
|
||||
return "left-full top-1/2 -translate-y-1/2 ml-2"
|
||||
default: // "top"
|
||||
return "bottom-full left-1/2 -translate-x-1/2 mb-2"
|
||||
}
|
||||
}
|
||||
|
||||
// tooltipRevealCls returns the CSS reveal classes for the given trigger mode.
|
||||
func tooltipRevealCls(trigger string) string {
|
||||
if trigger == "focus" {
|
||||
return "invisible opacity-0 transition-opacity group-focus-within:visible group-focus-within:opacity-100"
|
||||
}
|
||||
return "invisible opacity-0 transition-opacity group-hover:visible group-hover:opacity-100"
|
||||
}
|
||||
|
||||
// TooltipProps configures Tooltip. Trigger is "hover" (default) or "focus";
|
||||
// Placement is "top" (default), "bottom", "left" or "right".
|
||||
type TooltipProps struct {
|
||||
Content *vdom.VNode
|
||||
Trigger string
|
||||
Placement string
|
||||
Class string
|
||||
}
|
||||
|
||||
// Tooltip wraps children (the trigger) and shows Content in a floating bubble on
|
||||
// hover (or keyboard focus when Trigger is "focus").
|
||||
func Tooltip(p TooltipProps, children ...*vdom.VNode) *vdom.VNode {
|
||||
placement := pick(p.Placement, "top")
|
||||
|
||||
bubble := vdom.El("div",
|
||||
vdom.Attr("role", "tooltip"),
|
||||
vdom.Attr("data-floating-content", "true"),
|
||||
vdom.Attr("class", cx("absolute z-[110]", tooltipPlacementCls(placement), tooltipCls, tooltipRevealCls(p.Trigger))),
|
||||
)
|
||||
if p.Content != nil {
|
||||
bubble.Children = append(bubble.Children, p.Content)
|
||||
}
|
||||
bubble.Children = append(bubble.Children, vdom.El("div", vdom.Attr("class", tooltipArrowCls(placement))))
|
||||
|
||||
// The wrapper carries `group` + `tabindex` so both hover and keyboard-focus
|
||||
// reveal work in CSS. inline-block mirrors the TSX trigger's display.
|
||||
mods := []vdom.Mod{
|
||||
vdom.Attr("class", cx("group relative inline-block", p.Class)),
|
||||
vdom.Attr("tabindex", "0"),
|
||||
}
|
||||
mods = kids(mods, children)
|
||||
mods = append(mods, bubble)
|
||||
return vdom.El("span", mods...)
|
||||
}
|
||||
|
||||
// HoverTooltip is Tooltip with hover reveal (the TSX default variant).
|
||||
func HoverTooltip(content *vdom.VNode, placement, class string, children ...*vdom.VNode) *vdom.VNode {
|
||||
return Tooltip(TooltipProps{Content: content, Trigger: "hover", Placement: placement, Class: class}, children...)
|
||||
}
|
||||
|
||||
// FocusTooltip is Tooltip revealed on keyboard focus.
|
||||
func FocusTooltip(content *vdom.VNode, placement, class string, children ...*vdom.VNode) *vdom.VNode {
|
||||
return Tooltip(TooltipProps{Content: content, Trigger: "focus", Placement: placement, Class: class}, children...)
|
||||
}
|
||||
188
go/webui/tutorial.go
Normal file
188
go/webui/tutorial.go
Normal file
@@ -0,0 +1,188 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"kjol/vdom"
|
||||
)
|
||||
|
||||
// Port of web/kit/Tutorial.tsx.
|
||||
//
|
||||
// Tutorial is a guided-tour / coachmark overlay: it dims the page, spotlights a
|
||||
// target element, and floats a popover card with step navigation. The Solid
|
||||
// source leans heavily on browser-only capabilities the neutral vdom runtime
|
||||
// does not have (DOM measurement via getBoundingClientRect, portals, effects,
|
||||
// timers, scroll/resize listeners, window sizing). What is ported vs. dropped:
|
||||
//
|
||||
// - NOTE: calculatePopoverPosition — the viewport-aware placement math that
|
||||
// anchors and flips the popover around the target rect — is DROPPED. The
|
||||
// popover is statically centered with Tailwind instead of computed coords.
|
||||
// - NOTE: SpotlightOverlay's measured cutout (a giant box-shadow ring drawn
|
||||
// around the target's DOMRect) is APPROXIMATED by a plain dimmed backdrop,
|
||||
// which is the source's own no-target fallback.
|
||||
// - NOTE: PopoverArrow (the little triangle pointing at the target) is DROPPED,
|
||||
// since there is no target position to point at.
|
||||
// - NOTE: the fade/scale/slide transitions and the requestAnimationFrame /
|
||||
// setTimeout choreography are DROPPED; the card renders in its final state.
|
||||
// - NOTE: Solid context (TutorialProvider/useTutorial) + signals collapse to
|
||||
// plain props — the caller owns the active flag, the current-step index, and
|
||||
// the next/prev/close callbacks (read at the call site, as per kit convention).
|
||||
// - NOTE: per-step onEnter/onLeave lifecycle hooks (effect-driven) are DROPPED,
|
||||
// as is the string/function/null target union — Target here is a plain CSS
|
||||
// selector kept for reference only (nothing measures or scrolls to it).
|
||||
// - NOTE: SpotlightPadding, Placement, and Offset are retained for API parity
|
||||
// but are unused, because there is no positioning/spotlight to apply them to.
|
||||
|
||||
// TutorialStep is one stop in a guided tour. Content is the body VNode (the TSX
|
||||
// JSXElement). Target is the CSS selector of the element the step would spotlight
|
||||
// (see file NOTE — not measured here). Placement/Offset are kept for API parity
|
||||
// but are not applied.
|
||||
type TutorialStep struct {
|
||||
Title string
|
||||
Content *vdom.VNode
|
||||
Target string
|
||||
Placement string
|
||||
Offset int
|
||||
}
|
||||
|
||||
// TutorialProps drives the tour overlay. CurrentStep is the active index as a
|
||||
// plain value (the state that was a Solid signal now lives with the caller).
|
||||
// Active gates whether the overlay renders at all. OnNext/OnPrev advance the
|
||||
// tour; OnClose ends it (shared by the backdrop click, the close button, and the
|
||||
// Finish button on the last step).
|
||||
type TutorialProps struct {
|
||||
Steps []TutorialStep
|
||||
CurrentStep int
|
||||
Active bool
|
||||
SpotlightPadding int
|
||||
OnNext func()
|
||||
OnPrev func()
|
||||
OnClose func()
|
||||
Class string
|
||||
}
|
||||
|
||||
// tutorialOverlay is the dimmed backdrop. NOTE: this approximates SpotlightOverlay
|
||||
// without the measured spotlight cutout (see file NOTE); clicking it ends the tour.
|
||||
func tutorialOverlay(p TutorialProps) *vdom.VNode {
|
||||
mods := []vdom.Mod{vdom.Attr("class", "fixed inset-0 bg-black/50 z-150")}
|
||||
if p.OnClose != nil {
|
||||
mods = append(mods, vdom.On(vdom.EVENT_CLICK, p.OnClose))
|
||||
}
|
||||
return vdom.El("div", mods...)
|
||||
}
|
||||
|
||||
// tutorialPopover renders the tooltip card (title + "X of N" + close, body,
|
||||
// prev/next controls, and the step dots) for step idx. NOTE: it is statically
|
||||
// centered rather than anchored to the target (see file NOTE).
|
||||
func tutorialPopover(p TutorialProps, idx int) *vdom.VNode {
|
||||
step := p.Steps[idx]
|
||||
total := len(p.Steps)
|
||||
|
||||
// Header: optional title + step counter, and a close button.
|
||||
headerLeft := []vdom.Mod{vdom.Attr("class", "flex items-center gap-2")}
|
||||
if step.Title != "" {
|
||||
headerLeft = append(headerLeft, vdom.El("span",
|
||||
vdom.Attr("class", "font-medium text-neutral-900"),
|
||||
vdom.Text(step.Title)))
|
||||
}
|
||||
headerLeft = append(headerLeft, vdom.El("span",
|
||||
vdom.Attr("class", "text-xs text-neutral-500"),
|
||||
vdom.Text(strconv.Itoa(idx+1)+" of "+strconv.Itoa(total))))
|
||||
|
||||
closeMods := []vdom.Mod{
|
||||
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"),
|
||||
Icon("xmark", 18, ""),
|
||||
}
|
||||
if p.OnClose != nil {
|
||||
closeMods = append(closeMods, vdom.On(vdom.EVENT_CLICK, p.OnClose))
|
||||
}
|
||||
header := vdom.El("div", vdom.Attr("class", "flex items-center justify-between p-4 pb-2"),
|
||||
vdom.El("div", headerLeft...),
|
||||
vdom.El("button", closeMods...),
|
||||
)
|
||||
|
||||
// Body content.
|
||||
bodyMods := []vdom.Mod{vdom.Attr("class", "px-4 pb-4 text-sm text-neutral-700")}
|
||||
if step.Content != nil {
|
||||
bodyMods = append(bodyMods, step.Content)
|
||||
}
|
||||
body := vdom.El("div", bodyMods...)
|
||||
|
||||
// Controls: Previous on the left (hidden on the first step); Next/Finish right.
|
||||
isFirst := idx == 0
|
||||
isLast := idx == total-1
|
||||
|
||||
leftMods := []vdom.Mod{}
|
||||
if !isFirst {
|
||||
leftMods = append(leftMods, Button(ButtonProps{Color: ButtonWhite, Small: true, Text: "Previous", OnClick: p.OnPrev}))
|
||||
}
|
||||
var advance *vdom.VNode
|
||||
if isLast {
|
||||
advance = Button(ButtonProps{Color: ButtonBlue, Small: true, Text: "Finish", OnClick: p.OnClose})
|
||||
} else {
|
||||
advance = Button(ButtonProps{Color: ButtonBlue, Small: true, Text: "Next", OnClick: p.OnNext})
|
||||
}
|
||||
controls := vdom.El("div", vdom.Attr("class", "flex items-center justify-between px-4 pb-4 gap-2"),
|
||||
vdom.El("div", leftMods...),
|
||||
vdom.El("div", vdom.Attr("class", "flex gap-2"), advance),
|
||||
)
|
||||
|
||||
// NOTE: original card class kept verbatim; the centering utilities
|
||||
// (left/top/-translate) are the static stand-in for computed positioning.
|
||||
popMods := []vdom.Mod{
|
||||
vdom.Attr("class", cx("fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-200 bg-white rounded-default shadow-lg border border-neutral-200 max-w-sm", p.Class)),
|
||||
header, body, controls,
|
||||
}
|
||||
|
||||
// Step dots (only when there is more than one step).
|
||||
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"
|
||||
if i == idx {
|
||||
state = "bg-sky-600 scale-110"
|
||||
}
|
||||
dotMods = append(dotMods, vdom.El("div",
|
||||
vdom.Attr("class", cx("w-2 h-2 rounded-full transition-all duration-300 ease-in-out", state))))
|
||||
}
|
||||
popMods = append(popMods, vdom.El("div", dotMods...))
|
||||
}
|
||||
|
||||
return vdom.El("div", popMods...)
|
||||
}
|
||||
|
||||
// TutorialProvider renders children, and — when the tour is Active — the dimmed
|
||||
// backdrop plus the popover card for the current step over the top. The wrapper
|
||||
// uses display:contents so it does not introduce its own layout box (the Solid
|
||||
// source returned a fragment). Returns just the children when inactive or empty.
|
||||
//
|
||||
// NOTE: unlike the Solid provider, children cannot read tour state via context;
|
||||
// the caller threads Active/CurrentStep/callbacks in through TutorialProps.
|
||||
func TutorialProvider(p TutorialProps, children ...*vdom.VNode) *vdom.VNode {
|
||||
mods := kids([]vdom.Mod{vdom.Attr("class", "contents")}, children)
|
||||
|
||||
if p.Active && len(p.Steps) > 0 {
|
||||
idx := p.CurrentStep
|
||||
if idx < 0 {
|
||||
idx = 0
|
||||
}
|
||||
if idx > len(p.Steps)-1 {
|
||||
idx = len(p.Steps) - 1
|
||||
}
|
||||
mods = append(mods, tutorialOverlay(p), tutorialPopover(p, idx))
|
||||
}
|
||||
|
||||
return vdom.El("div", mods...)
|
||||
}
|
||||
|
||||
// StartTutorialButton is a blue button that kicks off the tour. onStart is the
|
||||
// caller's start handler (which decides the starting step index). With no
|
||||
// children it renders the default "Start Tutorial" label.
|
||||
func StartTutorialButton(onStart func(), class string, children ...*vdom.VNode) *vdom.VNode {
|
||||
bp := ButtonProps{Color: ButtonBlue, Class: class, OnClick: onStart}
|
||||
if len(children) == 0 {
|
||||
bp.Text = "Start Tutorial"
|
||||
}
|
||||
return Button(bp, children...)
|
||||
}
|
||||
147
go/webui/validation.go
Normal file
147
go/webui/validation.go
Normal file
@@ -0,0 +1,147 @@
|
||||
// Port of web/kit/Validation.ts.
|
||||
package webui
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// validationNonDigit matches every non-digit rune (the JS /\D/g).
|
||||
var validationNonDigit = regexp.MustCompile(`\D`)
|
||||
|
||||
// validationDigits strips every non-digit character.
|
||||
func validationDigits(s string) string {
|
||||
return validationNonDigit.ReplaceAllString(s, "")
|
||||
}
|
||||
|
||||
// Validation describes a single form field's validation inputs. Solid's reactive
|
||||
// Accessor<T> values collapse to plain values here: the caller reads its signals
|
||||
// at the call site and passes the current values. FieldBlur is optional (nil
|
||||
// falls back to Field), as is IsValidFunc.
|
||||
type Validation struct {
|
||||
ID string // snake_case identifier that is "touched"
|
||||
Name string // human-readable field name, used in messages
|
||||
Required bool // whether an empty value is an error
|
||||
Touched map[string]bool // which field IDs the user has interacted with
|
||||
Field string // the live field value
|
||||
FieldBlur *string // the value at last blur; nil uses Field
|
||||
IsValidFunc func(string) bool
|
||||
InvalidMsg string // custom "is invalid" message; empty uses a default
|
||||
}
|
||||
|
||||
// CreateValidation computes the validation error message for a field, or "" when
|
||||
// valid. This is the collapsed (non-reactive) form of the Solid createMemo: the
|
||||
// caller re-invokes it whenever the underlying signals change.
|
||||
func CreateValidation(v Validation) string {
|
||||
field := strings.TrimSpace(v.Field)
|
||||
// When no blur value is provided, fall back to the live value so the
|
||||
// "has value but not blurred yet" guard is always false and validation
|
||||
// runs against the live value instead.
|
||||
fieldBlur := field
|
||||
if v.FieldBlur != nil {
|
||||
fieldBlur = strings.TrimSpace(*v.FieldBlur)
|
||||
}
|
||||
|
||||
if !v.Touched[v.ID] || (field != "" && fieldBlur == "") || (v.IsValidFunc != nil && v.IsValidFunc(field)) {
|
||||
return ""
|
||||
}
|
||||
if v.Required && field == "" {
|
||||
return v.Name + " is required"
|
||||
}
|
||||
if v.IsValidFunc != nil && !v.IsValidFunc(fieldBlur) {
|
||||
if v.InvalidMsg != "" {
|
||||
return v.InvalidMsg
|
||||
}
|
||||
return v.Name + " is invalid"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// IsPhoneNumberValid reports whether phoneNumber has exactly 10 digits.
|
||||
func IsPhoneNumberValid(phoneNumber string) bool {
|
||||
return len(validationDigits(phoneNumber)) == 10
|
||||
}
|
||||
|
||||
var validationEmailRegex = regexp.MustCompile("^[-!#$%&'*+/0-9=?A-Z^_a-z`{|}~](\\.?[-!#$%&'*+/0-9=?A-Z^_a-z`{|}~])*@[a-zA-Z0-9](-*\\.?[a-zA-Z0-9])*\\.[a-zA-Z](-?[a-zA-Z0-9])+$")
|
||||
|
||||
// IsEmailValid reports whether email is a syntactically valid email address.
|
||||
func IsEmailValid(email string) bool {
|
||||
if email == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
emailParts := strings.Split(email, "@")
|
||||
if len(emailParts) != 2 {
|
||||
return false
|
||||
}
|
||||
|
||||
account := emailParts[0]
|
||||
address := emailParts[1]
|
||||
|
||||
if len(account) > 64 {
|
||||
return false
|
||||
} else if len(address) > 255 {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, part := range strings.Split(address, ".") {
|
||||
if len(part) > 63 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return validationEmailRegex.MatchString(email)
|
||||
}
|
||||
|
||||
var validationURLRegex = regexp.MustCompile("[(http(s)?)://(www\\.)?a-zA-Z0-9@:%._\\+~#=]{2,256}\\.[a-z]{2,6}\\b([-a-zA-Z0-9@:%_\\+.~#?&//=]*)")
|
||||
|
||||
// IsURLValid reports whether url contains a substring that looks like a URL.
|
||||
func IsURLValid(url string) bool {
|
||||
return validationURLRegex.MatchString(url)
|
||||
}
|
||||
|
||||
// IsZipCodeValid reports whether zip has exactly 5 or 9 digits.
|
||||
func IsZipCodeValid(zip string) bool {
|
||||
rawZip := validationDigits(zip)
|
||||
return len(rawZip) == 5 || len(rawZip) == 9
|
||||
}
|
||||
|
||||
// IsTaxIDValid reports whether id has exactly 9 digits.
|
||||
func IsTaxIDValid(id string) bool {
|
||||
return len(validationDigits(id)) == 9
|
||||
}
|
||||
|
||||
// IsAtLeastMinChars reports whether input is at least minLen characters long.
|
||||
func IsAtLeastMinChars(input string, minLen int) bool {
|
||||
return utf8.RuneCountInString(input) >= minLen
|
||||
}
|
||||
|
||||
// IsWithinMaxChars reports whether input is at most maxLen characters long.
|
||||
func IsWithinMaxChars(input string, maxLen int) bool {
|
||||
return utf8.RuneCountInString(input) <= maxLen
|
||||
}
|
||||
|
||||
var validationNameRegex = regexp.MustCompile(`^[\p{L}]*[\p{L} '\-]*[\p{L}]$`)
|
||||
|
||||
// IsNameValid reports whether name consists only of letters, spaces, hyphens,
|
||||
// and apostrophes (and begins/ends with a letter).
|
||||
func IsNameValid(name string) bool {
|
||||
return validationNameRegex.MatchString(name)
|
||||
}
|
||||
|
||||
var validationUsernameRegex = regexp.MustCompile(`^[A-Za-z0-9]*$`)
|
||||
|
||||
// IsUsernameValid reports an error message when the username is invalid (5-50
|
||||
// alphanumeric characters), or "" when valid.
|
||||
func IsUsernameValid(username string) string {
|
||||
if n := utf8.RuneCountInString(username); n < 5 || n > 50 {
|
||||
return "Username must have 5-50 characters"
|
||||
}
|
||||
|
||||
if !validationUsernameRegex.MatchString(username) {
|
||||
return "Username must only contain alphanumeric characters"
|
||||
}
|
||||
|
||||
return "" // Valid
|
||||
}
|
||||
64
go/webui/webui.go
Normal file
64
go/webui/webui.go
Normal file
@@ -0,0 +1,64 @@
|
||||
// Package webui is a Go/WebAssembly UI component kit for the gowasm engine — a
|
||||
// direct port of kjol's Solid.js TSX kit (web/kit). Components are neutral
|
||||
// *vdom.VNode builders: they render to HTML on the server (SSR) and hydrate on
|
||||
// the client, styled with Tailwind utility classes.
|
||||
//
|
||||
// Port conventions (how the TSX maps to Go):
|
||||
//
|
||||
// - A TSX component `function Foo(props)` becomes `func Foo(p FooProps, children ...*vdom.VNode) *vdom.VNode`
|
||||
// (children variadic when the component wraps content).
|
||||
// - Solid's reactive accessors (`value | () => value`) collapse away: the whole
|
||||
// component re-renders on a signal write (React-style), so callers pass plain
|
||||
// current values — read a signal with `.Get()` at the call site.
|
||||
// - `onclick={fn}` → `vdom.On(vdom.EVENT_CLICK, fn)`; handlers needing the event use
|
||||
// `OnEvent`. `class` overrides are the trailing `Class` field, joined via cx.
|
||||
// - Tailwind classes are copied verbatim so kjol/cmd/twcss (scanning these .go
|
||||
// files) emits the matching CSS. Custom tokens (rounded-default, bg-primary,
|
||||
// text-text-heading, …) come from the app's @theme block.
|
||||
// - Browser-only behavior (floating-ui positioning, portals, focus traps,
|
||||
// element measurement) has no equivalent in the neutral runtime; those
|
||||
// components port their structure + Tailwind + signal/event wiring, and
|
||||
// approximate positioning with CSS where possible. Such gaps are marked
|
||||
// with a NOTE in the component's file.
|
||||
package webui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"kjol/vdom"
|
||||
)
|
||||
|
||||
// cx joins non-empty class fragments with single spaces (a tiny clsx). Trailing
|
||||
// user `Class` overrides go last so they win under equal specificity.
|
||||
func cx(parts ...string) string {
|
||||
var b strings.Builder
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
if b.Len() > 0 {
|
||||
b.WriteByte(' ')
|
||||
}
|
||||
b.WriteString(p)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// pick returns v if non-empty, else def (for defaulted string props like type).
|
||||
func pick(v, def string) string {
|
||||
if v == "" {
|
||||
return def
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// kids appends children VNodes onto a mod slice (helper for the props+children shape).
|
||||
func kids(mods []vdom.Mod, children []*vdom.VNode) []vdom.Mod {
|
||||
for _, c := range children {
|
||||
if c != nil {
|
||||
mods = append(mods, c)
|
||||
}
|
||||
}
|
||||
return mods
|
||||
}
|
||||
Reference in New Issue
Block a user