Merge branch 'master' of git.maxamundsen.dev:maxamundsen/kjol

This commit is contained in:
2026-07-13 09:17:19 -04:00
77 changed files with 10756 additions and 1 deletions

35
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,35 @@
{
// Debug configs for the gowasm example. cwd is the example dir because the
// dev server resolves ./wwwroot, ./app, ./wasm and the watched engine dir
// relative to it.
"version": "0.2.0",
"configurations": [
{
"name": "gowasm: dev server",
"type": "go",
"request": "launch",
"mode": "auto",
"program": "${workspaceFolder}/go/cmd/examples/go-wasm-web/server",
"cwd": "${workspaceFolder}/go/cmd/examples/go-wasm-web",
"args": ["-addr", ":8085"]
},
{
"name": "gowasm: dev server (no watch)",
"type": "go",
"request": "launch",
"mode": "auto",
"program": "${workspaceFolder}/go/cmd/examples/go-wasm-web/server",
"cwd": "${workspaceFolder}/go/cmd/examples/go-wasm-web",
"args": ["-watch=false"]
},
{
"name": "gowasm: codegen (wasmgen)",
"type": "go",
"request": "launch",
"mode": "auto",
"program": "${workspaceFolder}/go/cmd/wasmgen",
"cwd": "${workspaceFolder}/go/cmd/examples/go-wasm-web",
"args": ["./app"]
}
]
}

73
.vscode/tasks.json vendored Normal file
View File

@@ -0,0 +1,73 @@
{
// Tasks for the kjol repo. The gowasm example is a nested module at
// go/cmd/examples/go-wasm-web, so its tasks set cwd there; the module-wide
// Go tasks run in go/.
"version": "2.0.0",
"tasks": [
{
"label": "gowasm: dev server (hot reload)",
"detail": "Run the go-wasm-web example: codegen + SSR + hot reload on :8085",
"type": "shell",
"command": "go run ./server",
"options": { "cwd": "${workspaceFolder}/go/cmd/examples/go-wasm-web" },
"isBackground": true,
"problemMatcher": {
"owner": "go",
"pattern": {
"regexp": "^(.*):(\\d+):(\\d+):\\s+(.*)$",
"file": 1, "line": 2, "column": 3, "message": 4
},
"background": {
"activeOnStart": true,
"beginsPattern": "rebuilding",
"endsPattern": "serving|reloading clients"
}
},
"presentation": { "reveal": "always", "panel": "dedicated", "clear": true },
"group": { "kind": "build", "isDefault": true }
},
{
"label": "gowasm: build (build.sh)",
"detail": "One-off build of the example: codegen + wasm + stage wasm_exec.js",
"type": "shell",
"command": "./build.sh",
"options": { "cwd": "${workspaceFolder}/go/cmd/examples/go-wasm-web" },
"problemMatcher": ["$go"],
"group": "build"
},
{
"label": "gowasm: codegen",
"detail": "Regenerate app/*.gen.go from //gowasm: directives",
"type": "shell",
"command": "go run kjol/cmd/wasmgen ./app",
"options": { "cwd": "${workspaceFolder}/go/cmd/examples/go-wasm-web" },
"problemMatcher": ["$go"],
"group": "build"
},
{
"label": "kjol: build ./...",
"detail": "Build the whole kjol module (excludes the nested example)",
"type": "shell",
"command": "go build ./...",
"options": { "cwd": "${workspaceFolder}/go" },
"problemMatcher": ["$go"],
"group": "build"
},
{
"label": "kjol: vet ./...",
"type": "shell",
"command": "go vet ./...",
"options": { "cwd": "${workspaceFolder}/go" },
"problemMatcher": ["$go"],
"group": "test"
},
{
"label": "kjol: test ./...",
"type": "shell",
"command": "go test ./...",
"options": { "cwd": "${workspaceFolder}/go" },
"problemMatcher": ["$go"],
"group": { "kind": "test", "isDefault": true }
}
]
}

View File

@@ -34,7 +34,12 @@ Language-first, **not** feature-first. Consequence: the **bundler is Go** and li
### go/ — module `kjol` ### go/ — module `kjol`
Packages, imported as `kjol/<name>`: `appenv basic chrono config csv dbutil finance httputil Packages, imported as `kjol/<name>`: `appenv basic chrono config csv dbutil finance httputil
l4g security snailmail validation bundler`, plus `cmd/{bundle,migrate,loc,passgen,typecheck}`. l4g security snailmail validation bundler`, plus the **gowasm** web-UI engine (`vdom`
`wasmruntime` `rsc` `wasmdevserver`, and `webui` — a Tailwind-styled component kit ported
from `web/kit`; author components in pure Go compiled to WebAssembly; all stdlib-only), and
`cmd/{bundle,migrate,loc,passgen,typecheck,wasmgen}`. A runnable
example lives in `cmd/examples/go-wasm-web` (its own nested module so its go-chart dep stays
out of kjol).
Build / test (run from repo root): Build / test (run from repo root):
``` ```

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

View 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); ... }
```

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

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

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

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

View 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(),
)
}
}

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

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

View 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()
}

View 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"

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

View 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 => ../../..

View 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=

Binary file not shown.

View 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()
}

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

View 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() {}

File diff suppressed because one or more lines are too long

Binary file not shown.

View 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;
};
}
}
})();

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

View File

@@ -1,6 +1,7 @@
package httputil package httputil
import ( import (
"encoding/gob"
"encoding/json" "encoding/json"
"net/http" "net/http"
) )
@@ -11,6 +12,12 @@ func RespondJSON(w http.ResponseWriter, statusCode int, data any) {
json.NewEncoder(w).Encode(data) 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) { func RespondError(w http.ResponseWriter, statusCode int, message string) {
RespondJSON(w, statusCode, map[string]string{"error": message}) RespondJSON(w, statusCode, map[string]string{"error": message})
} }

93
go/rsc/client_wasm.go Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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('"')
}
}

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

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

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

File diff suppressed because it is too large Load Diff

392
go/webui/fuzzymatch.go Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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),
),
)
}

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