initial port of the UI kit
This commit is contained in:
@@ -2,10 +2,11 @@
|
||||
|
||||
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, and a **flash-free, state-preserving hot reload**.
|
||||
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}`); this directory is only the app
|
||||
that consumes them.
|
||||
(`kjol/go/{vdom,wasmruntime,rsc,wasmdevserver,webui}`); this directory is only the
|
||||
app that consumes them.
|
||||
|
||||
## Run it
|
||||
|
||||
@@ -14,12 +15,16 @@ 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` and `/server` 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.
|
||||
Open http://localhost:8085. `/` and `/about` use the light **public** layout;
|
||||
`/chart`, `/server`, 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.
|
||||
|
||||
(`build.sh` does a one-off build instead of running the dev server.)
|
||||
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
|
||||
|
||||
@@ -35,11 +40,13 @@ nested module; build it from this directory.
|
||||
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
|
||||
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/ bootstrap.js, bootstrap.min.css (+ generated wasm_exec.js, app.wasm)
|
||||
wwwroot/ wasmboot.js (+ generated app.css, wasm_exec.js, app.wasm)
|
||||
```
|
||||
|
||||
## How it maps onto the engine (top-level `kjol` packages)
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
chart "github.com/wcharczuk/go-chart/v2"
|
||||
|
||||
. "kjol/vdom"
|
||||
ui "kjol/webui"
|
||||
)
|
||||
|
||||
var chartLabels = []string{"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"}
|
||||
@@ -64,17 +65,16 @@ func ChartPage(d Deps) func() *VNode {
|
||||
data := NewSignal(fixedChartData())
|
||||
return func() *VNode {
|
||||
values := data.Get()
|
||||
return Div(
|
||||
H2(Attr("class", "h4 mb-3"), Text("Charts — go-chart (SSR + hydrate)")),
|
||||
P(Attr("class", "text-secondary"),
|
||||
Text("Rendered to SVG on the server, hydrated on the client; Shuffle re-renders client-side.")),
|
||||
Button(Attr("class", "btn btn-primary mb-3"),
|
||||
On(EVENT_CLICK, func() { data.Set(randomValues()) }), Text("Shuffle Data")),
|
||||
Div(Attr("class", "row"),
|
||||
Div(Attr("class", "col-12 col-lg-7 mb-3"),
|
||||
Div(Attr("class", "border rounded p-2 bg-white overflow-auto"), Raw(barSVG(values)))),
|
||||
Div(Attr("class", "col-12 col-lg-5 mb-3"),
|
||||
Div(Attr("class", "border rounded p-2 bg-white overflow-auto"), Raw(pieSVG(values)))),
|
||||
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))),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
186
go/cmd/examples/go-wasm-web/app/kit.go
Normal file
186
go/cmd/examples/go-wasm-web/app/kit.go
Normal file
@@ -0,0 +1,186 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
. "kjol/vdom"
|
||||
ui "kjol/webui"
|
||||
)
|
||||
|
||||
// row is a flex/grid container helper (appends *VNode children as Mods).
|
||||
func row(class string, children ...*VNode) *VNode {
|
||||
mods := []Mod{Attr("class", class)}
|
||||
for _, c := range children {
|
||||
mods = append(mods, c)
|
||||
}
|
||||
return Div(mods...)
|
||||
}
|
||||
|
||||
// kitSection wraps a labeled demo block in a card.
|
||||
func kitSection(title string, body ...*VNode) *VNode {
|
||||
return ui.Card("",
|
||||
ui.CardHeader("", Text(title)),
|
||||
row("flex flex-col gap-4", body...),
|
||||
)
|
||||
}
|
||||
|
||||
func ptRow(name, plan string, status *VNode) *VNode {
|
||||
td := func(cls string, c *VNode) *VNode { return El("td", Attr("class", "px-3 py-2 text-sm "+cls), c) }
|
||||
return El("tr",
|
||||
td("text-neutral-800", Text(name)),
|
||||
td("text-neutral-600", Text(plan)),
|
||||
El("td", Attr("class", "px-3 py-2 text-sm text-right"), status),
|
||||
)
|
||||
}
|
||||
|
||||
//gowasm:page /kit layout=app
|
||||
func KitPage(d Deps) func() *VNode {
|
||||
// Interactive demos own their state via signals (a write re-renders).
|
||||
tab := NewSignal(0)
|
||||
acc := NewSignal(0)
|
||||
notify := NewSignal(true)
|
||||
span := NewSignal("week")
|
||||
modalOpen := NewSignal(false)
|
||||
menuOpen := NewSignal(false)
|
||||
name := NewSignal("")
|
||||
email := NewSignal("")
|
||||
plan := NewSignal("pro")
|
||||
|
||||
return func() *VNode {
|
||||
return Div(Attr("class", "space-y-8"),
|
||||
Div(
|
||||
H2(Attr("class", "text-2xl font-semibold tracking-tight text-text-heading"), Text("UI Kit")),
|
||||
P(Attr("class", "mt-1 text-neutral-500"),
|
||||
Text("The kjol/webui components, ported from the Solid.js kit and styled with Tailwind. "+
|
||||
"Interactive components are driven by signals; overlays and floating elements render "+
|
||||
"in their static form (see the note at the bottom).")),
|
||||
),
|
||||
|
||||
kitSection("Buttons",
|
||||
row("flex flex-wrap items-center gap-2",
|
||||
ui.Button(ui.ButtonProps{Color: ui.ButtonPrimary, Text: "Primary"}),
|
||||
ui.Button(ui.ButtonProps{Color: ui.ButtonGreen, Text: "Green"}),
|
||||
ui.Button(ui.ButtonProps{Color: ui.ButtonRed, Text: "Red"}),
|
||||
ui.Button(ui.ButtonProps{Color: ui.ButtonBlue, Text: "Blue"}),
|
||||
ui.Button(ui.ButtonProps{Color: ui.ButtonNeutral, Text: "Neutral"}),
|
||||
),
|
||||
row("flex flex-wrap items-center gap-2",
|
||||
ui.Button(ui.ButtonProps{Color: ui.ButtonPrimary, Outline: true, Text: "Outline"}),
|
||||
ui.Button(ui.ButtonProps{Color: ui.ButtonRed, Outline: true, Text: "Danger"}),
|
||||
ui.Button(ui.ButtonProps{Color: ui.ButtonGreen, Small: true, Icon: "check", Text: "Small + icon"}),
|
||||
ui.Button(ui.ButtonProps{Color: ui.ButtonNeutral, Icon: "plus"}),
|
||||
ui.Button(ui.ButtonProps{Color: ui.ButtonPrimary, Text: "Disabled", Disabled: true}),
|
||||
),
|
||||
),
|
||||
|
||||
kitSection("Badges",
|
||||
row("flex flex-wrap items-center gap-2",
|
||||
ui.Badge(ui.BadgeProps{Color: ui.BadgeGreen}, Text("active")),
|
||||
ui.Badge(ui.BadgeProps{Color: ui.BadgeRed}, Text("failed")),
|
||||
ui.Badge(ui.BadgeProps{Color: ui.BadgeBlue}, Text("info")),
|
||||
ui.Badge(ui.BadgeProps{Color: ui.BadgeAmber, Pill: true}, Text("pending")),
|
||||
ui.Badge(ui.BadgeProps{Color: ui.BadgeNeutral}, Text("default")),
|
||||
ui.Badge(ui.BadgeProps{Color: ui.BadgeMuted}, Text("muted")),
|
||||
),
|
||||
),
|
||||
|
||||
kitSection("Alerts",
|
||||
ui.Alert(ui.AlertBlue, "Heads up", Text("An informational message with a header.")),
|
||||
ui.Alert(ui.AlertGreen, "", Text("A success alert without a header.")),
|
||||
ui.Alert(ui.AlertYellow, "Warning", Text("Something needs your attention.")),
|
||||
ui.Alert(ui.AlertRed, "Error", Text("Something went wrong.")),
|
||||
),
|
||||
|
||||
kitSection("Toggles & segmented control",
|
||||
ui.ToggleSwitch(notify.Get(), func(v bool) { notify.Set(v) }, "Email notifications", "Send me product updates", false, ""),
|
||||
ui.SegmentedButtons([]ui.SegmentedButtonOption{
|
||||
{Value: "day", Label: "Day"},
|
||||
{Value: "week", Label: "Week"},
|
||||
{Value: "month", Label: "Month"},
|
||||
}, span.Get(), func(v string) { span.Set(v) }, false, "max-w-xs"),
|
||||
),
|
||||
|
||||
kitSection("Tabs",
|
||||
ui.TabGroup(ui.TabGroupProps{
|
||||
Items: []ui.TabItem{
|
||||
{Title: "Overview", Content: P(Attr("class", "pt-3 text-sm text-neutral-600"), Text("The overview panel."))},
|
||||
{Title: "Details", Content: P(Attr("class", "pt-3 text-sm text-neutral-600"), Text("The details panel."))},
|
||||
{Title: "Activity", Badge: 3, Content: P(Attr("class", "pt-3 text-sm text-neutral-600"), Text("The activity panel (3 new)."))},
|
||||
},
|
||||
ActiveIndex: tab.Get(),
|
||||
OnTabChange: func(i int) { tab.Set(i) },
|
||||
}),
|
||||
),
|
||||
|
||||
kitSection("Accordion",
|
||||
ui.SingleAccordion([]ui.AccordionItemData{
|
||||
{Title: "What is gowasm?", Content: P(Attr("class", "text-sm text-neutral-600"), Text("A tiny Go→WebAssembly UI engine."))},
|
||||
{Title: "Is it isomorphic?", Content: P(Attr("class", "text-sm text-neutral-600"), Text("Yes — the same Go renders on the server (SSR) and hydrates on the client."))},
|
||||
{Title: "How is it styled?", Content: P(Attr("class", "text-sm text-neutral-600"), Text("Tailwind utility classes, compiled by kjol's native Tailwind engine."))},
|
||||
}, acc.Get(), func(i int) { acc.Set(i) }),
|
||||
),
|
||||
|
||||
kitSection("Forms",
|
||||
row("grid gap-4 sm:grid-cols-3",
|
||||
row("flex flex-col gap-1", ui.FormLabel(ui.FormLabelProps{}, Text("Name")),
|
||||
ui.FormInput(ui.FormInputProps{Value: name.Get(), Placeholder: "Ada Lovelace", OnInput: func(v string) { name.Set(v) }})),
|
||||
row("flex flex-col gap-1", ui.FormLabel(ui.FormLabelProps{}, Text("Email")),
|
||||
ui.FormEmailInput(ui.FormInputProps{Value: email.Get(), Placeholder: "ada@example.com", OnInput: func(v string) { email.Set(v) }}, true)),
|
||||
row("flex flex-col gap-1", ui.FormLabel(ui.FormLabelProps{}, Text("Plan")),
|
||||
ui.FormSelect(ui.FormSelectProps{Value: plan.Get(), OnChange: func(v string) { plan.Set(v) }},
|
||||
ui.FormOption("free", "Free", false),
|
||||
ui.FormOption("pro", "Pro", false),
|
||||
ui.FormOption("enterprise", "Enterprise", false))),
|
||||
),
|
||||
P(Attr("class", "text-xs text-neutral-500"),
|
||||
Text("Live: name=\""+name.Get()+"\" email=\""+email.Get()+"\" plan=\""+plan.Get()+"\"")),
|
||||
),
|
||||
|
||||
kitSection("Table",
|
||||
ui.PrettyTable(
|
||||
[]ui.PrettyTableColumn{
|
||||
{DisplayName: "Name"},
|
||||
{DisplayName: "Plan"},
|
||||
{DisplayName: "Status", DisplayPosition: ui.PrettyTableColRight},
|
||||
},
|
||||
ui.PrettyTableOptions{Hover: true, Alternate: true, SurroundingBorder: true, HeaderBorderY: true},
|
||||
ptRow("Ada Lovelace", "Pro", ui.Badge(ui.BadgeProps{Color: ui.BadgeGreen}, Text("active"))),
|
||||
ptRow("Alan Turing", "Free", ui.Badge(ui.BadgeProps{Color: ui.BadgeNeutral}, Text("trial"))),
|
||||
ptRow("Grace Hopper", "Enterprise", ui.Badge(ui.BadgeProps{Color: ui.BadgeBlue}, Text("invited"))),
|
||||
),
|
||||
),
|
||||
|
||||
kitSection("Overlays (interactive)",
|
||||
row("flex flex-wrap items-center gap-4",
|
||||
// Modal, toggled by a signal.
|
||||
ui.Button(ui.ButtonProps{Color: ui.ButtonPrimary, Text: "Open modal", OnClick: func() { modalOpen.Set(true) }}),
|
||||
// Menu, toggled by a signal.
|
||||
ui.Menu("relative inline-block",
|
||||
ui.MenuTrigger(func() { menuOpen.Set(!menuOpen.Get()) }, "",
|
||||
ui.Button(ui.ButtonProps{Color: ui.ButtonWhite, Text: "Menu ▾"})),
|
||||
ui.MenuContent(menuOpen.Get(), ui.MenuPlacementBottomStart, "",
|
||||
ui.MenuItem(ui.MenuItemProps{Icon: "check", OnClick: func() { menuOpen.Set(false) }}, Text("Profile")),
|
||||
ui.MenuItem(ui.MenuItemProps{OnClick: func() { menuOpen.Set(false) }}, Text("Settings")),
|
||||
ui.MenuDivider(""),
|
||||
ui.MenuItem(ui.MenuItemProps{OnClick: func() { menuOpen.Set(false) }}, Text("Sign out")),
|
||||
),
|
||||
),
|
||||
// Tooltip (pure CSS hover).
|
||||
ui.HoverTooltip(Span(Text("A CSS-only tooltip")), "top", "",
|
||||
ui.Button(ui.ButtonProps{Color: ui.ButtonNeutral, Outline: true, Text: "Hover me"})),
|
||||
),
|
||||
ui.Modal(ui.ModalProps{
|
||||
IsOpen: modalOpen.Get(),
|
||||
OnClose: func() { modalOpen.Set(false) },
|
||||
Size: ui.ModalMedium,
|
||||
Header: H3(Attr("class", "text-lg font-semibold text-text-heading"), Text("Example modal")),
|
||||
},
|
||||
P(Attr("class", "text-neutral-600"), Text("This modal is toggled by a signal; the backdrop and close button round-trip through OnClose.")),
|
||||
),
|
||||
),
|
||||
|
||||
ui.Alert(ui.AlertGray, "About this page",
|
||||
Text("Floating/positioned components (menus, tooltips, modals, dropdowns) are ported as "+
|
||||
"structure + Tailwind + signal/event wiring — the neutral runtime has no floating-ui, "+
|
||||
"portals, or element measurement, so positioning is approximated with static classes.")),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,12 @@
|
||||
// Package app holds the application's pages and components as standalone
|
||||
// functions (no central App struct). It is platform-neutral, so the SAME code
|
||||
// renders on the server (SSR) and hydrates on the client.
|
||||
// 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 cmd/wasmgen at build time):
|
||||
// Directives (processed by kjol/cmd/wasmgen at build time):
|
||||
//
|
||||
// //gowasm:page <path> [static] [layout=<name>]
|
||||
// marks a page factory as a route. `static`
|
||||
// pre-renders it on the server (SSR);
|
||||
// `layout=<name>` wraps it in a //gowasm:layout.
|
||||
// //gowasm:layout <name> marks a func(Deps, *VNode) *VNode as a named
|
||||
// layout that wraps a page's content.
|
||||
// //gowasm:server (see server_counter.go) marks a component
|
||||
// that runs on the server; the generated
|
||||
// client stub makes calling it identical to
|
||||
// calling any other component.
|
||||
//
|
||||
// The Routes() map, StaticPaths set, and RouteLayout/LayoutFor dispatch are all
|
||||
// generated from these directives.
|
||||
// //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 .
|
||||
@@ -25,9 +15,10 @@ import (
|
||||
"strconv"
|
||||
|
||||
. "kjol/vdom"
|
||||
ui "kjol/webui"
|
||||
)
|
||||
|
||||
// Deps are the client-only capabilities, injected so the pages stay neutral.
|
||||
// Deps are the client-only capabilities, injected so pages stay neutral.
|
||||
type Deps struct {
|
||||
Path func() string
|
||||
Navigate func(string)
|
||||
@@ -35,13 +26,11 @@ type Deps struct {
|
||||
|
||||
func itoa(n int) string { return strconv.Itoa(n) }
|
||||
|
||||
// Layout wraps a page's rendered content with shared chrome (nav, footer, …).
|
||||
// Layouts are declared with //gowasm:layout and selected per route via a page's
|
||||
// `layout=` directive; the generated LayoutFor dispatches by name.
|
||||
// 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. `routes` is
|
||||
// the generated route table; `d.Path()` selects both the page and its layout.
|
||||
// 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
|
||||
@@ -54,70 +43,71 @@ func Shell(d Deps, routes map[string]func() *VNode) *VNode {
|
||||
}
|
||||
|
||||
func notFound(path string) *VNode {
|
||||
return Div(
|
||||
H2(Attr("class", "h4 mb-3"), Text("Page not found")),
|
||||
P(Attr("class", "text-secondary"), Text("No route matches "+path+".")),
|
||||
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 (selected per route via `layout=` in //gowasm:page) ---------
|
||||
// --- layouts (Tailwind chrome) -------------------------------------------
|
||||
|
||||
// PublicLayout is the chrome for public/marketing pages: a light navbar with a
|
||||
// call-to-action into the app, and a footer. The func(Deps, *VNode) *VNode shape
|
||||
// is what //gowasm:layout expects.
|
||||
//
|
||||
//gowasm:layout public
|
||||
func PublicLayout(d Deps, content *VNode) *VNode {
|
||||
return Div(
|
||||
Nav(Attr("class", "navbar navbar-expand bg-light border-bottom mb-4"),
|
||||
Div(Attr("class", "container"),
|
||||
A(Attr("class", "navbar-brand fw-bold"), Attr("href", "/"), navigate(d, "/"), Text("gowasm")),
|
||||
Ul(Attr("class", "navbar-nav ms-auto align-items-center"),
|
||||
navItem(d, "/", "Home"),
|
||||
navItem(d, "/about", "About"),
|
||||
Li(Attr("class", "nav-item ms-2"),
|
||||
A(Attr("class", "btn btn-sm btn-primary"), Attr("href", "/chart"),
|
||||
navigate(d, "/chart"), Text("Open app →"))),
|
||||
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", "container"),
|
||||
Main(Attr("class", "mx-auto max-w-5xl px-4 py-8"),
|
||||
content,
|
||||
Footer(Attr("class", "text-secondary small border-top mt-5 pt-3"),
|
||||
Text("gowasm public site — a tiny Blazor-like engine in Go.")),
|
||||
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.")),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// AppLayout is the chrome for the application itself: a dark app navbar listing
|
||||
// the app's sections, plus a link back to the public site.
|
||||
//
|
||||
//gowasm:layout app
|
||||
func AppLayout(d Deps, content *VNode) *VNode {
|
||||
return Div(
|
||||
Nav(Attr("class", "navbar navbar-expand navbar-dark bg-dark mb-4"),
|
||||
Div(Attr("class", "container"),
|
||||
A(Attr("class", "navbar-brand fw-bold"), Attr("href", "/chart"), navigate(d, "/chart"), Text("gowasm · app")),
|
||||
Ul(Attr("class", "navbar-nav me-auto"),
|
||||
navItem(d, "/chart", "Chart"),
|
||||
navItem(d, "/server", "Server")),
|
||||
Ul(Attr("class", "navbar-nav"),
|
||||
navItem(d, "/", "Home")),
|
||||
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, "/kit", "UI Kit", true)),
|
||||
Ul(Attr("class", "ml-auto flex items-center"),
|
||||
navItem(d, "/", "Home", true)),
|
||||
)),
|
||||
Main(Attr("class", "container"), content),
|
||||
Main(Attr("class", "mx-auto max-w-5xl px-4 py-8"), content),
|
||||
)
|
||||
}
|
||||
|
||||
// navItem is a nav link that carries an active state on the current route.
|
||||
func navItem(d Deps, path, label string) *VNode {
|
||||
cls := "nav-link"
|
||||
if d.Path() == path {
|
||||
cls += " active"
|
||||
// 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(Attr("class", "nav-item"),
|
||||
A(Attr("class", cls), Attr("href", path), navigate(d, path), Text(label)))
|
||||
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. On the server
|
||||
// Navigate is nil, so the anchor falls back to a normal navigation.
|
||||
// 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 {
|
||||
@@ -129,31 +119,48 @@ func navigate(d Deps, path string) Mod {
|
||||
|
||||
// Counter is a presentational client component; state is owned by the caller.
|
||||
func Counter(label string, count *Signal[int]) *VNode {
|
||||
return Div(Attr("class", "counter card mb-2"),
|
||||
Div(Attr("class", "card-body py-2 d-flex align-items-center"),
|
||||
Span(Attr("class", "me-2 fw-semibold"), Text(label+": ")),
|
||||
Strong(Attr("class", "badge text-bg-primary me-2"), Text(itoa(count.Get()))),
|
||||
Div(Attr("class", "btn-group btn-group-sm ms-auto"),
|
||||
Button(Attr("class", "btn btn-outline-secondary"),
|
||||
On(EVENT_CLICK, func() { count.Update(func(v int) int { return v - 1 }) }), Text("−")),
|
||||
Button(Attr("class", "btn btn-outline-primary"),
|
||||
On(EVENT_CLICK, func() { count.Update(func(v int) int { return v + 1 }) }), Text("+")),
|
||||
)))
|
||||
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(
|
||||
H2(Attr("class", "h4 mb-3"), Text("Home — component composition")),
|
||||
P(Attr("class", "text-secondary"), Text("Two counters; the total is derived across them. Server-rendered, then hydrated.")),
|
||||
Counter("Apples", a),
|
||||
Counter("Bananas", b),
|
||||
Div(Attr("class", "alert alert-info d-flex justify-content-between align-items-center mt-3"),
|
||||
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", "fs-5"), Text(itoa(a.Get()+b.Get())))),
|
||||
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, "")),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -161,32 +168,34 @@ func HomePage(d Deps) func() *VNode {
|
||||
//gowasm:page /about static layout=public
|
||||
func AboutPage(d Deps) func() *VNode {
|
||||
return func() *VNode {
|
||||
return Div(
|
||||
H2(Attr("class", "h4 mb-3"), Text("About")),
|
||||
P(Attr("class", "lead"),
|
||||
Text("Components are standalone 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.")),
|
||||
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 — but 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. (Not `static`: the
|
||||
// client renders the stub, which round-trips, so there's nothing stable to
|
||||
// pre-render + hydrate.)
|
||||
// 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(
|
||||
H2(Attr("class", "h4 mb-3"), Text("Server component")),
|
||||
P(Attr("class", "text-secondary"),
|
||||
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. The call site is identical to a "+
|
||||
"client component.")),
|
||||
"and the returned render merges into the DOM.")),
|
||||
counter(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ func Routes(d Deps) map[string]func() *vdom.VNode {
|
||||
"/": HomePage(d),
|
||||
"/about": AboutPage(d),
|
||||
"/chart": ChartPage(d),
|
||||
"/kit": KitPage(d),
|
||||
"/server": ServerPage(d),
|
||||
}
|
||||
}
|
||||
@@ -25,6 +26,7 @@ var RouteLayout = map[string]string{
|
||||
"/": "public",
|
||||
"/about": "public",
|
||||
"/chart": "app",
|
||||
"/kit": "app",
|
||||
"/server": "app",
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
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
|
||||
@@ -34,19 +35,17 @@ func ServerCounter() func() *VNode {
|
||||
points.Set(append(points.Get(), clickPoint{T: time.Now().UnixMilli(), V: count.Get()}))
|
||||
}
|
||||
return func() *VNode {
|
||||
return Div(Attr("class", "card"),
|
||||
Div(Attr("class", "card-body"),
|
||||
Div(Attr("class", "d-flex align-items-center gap-2 mb-2"),
|
||||
Span(Text("Server counter: ")),
|
||||
Strong(Attr("class", "badge text-bg-success fs-6"), Text(strconv.Itoa(count.Get()))),
|
||||
Button(Attr("class", "btn btn-sm btn-outline-secondary"),
|
||||
On(EVENT_CLICK, func() { bump(-1) }), Text("−")),
|
||||
Button(Attr("class", "btn btn-sm btn-success"),
|
||||
On(EVENT_CLICK, func() { bump(1) }), Text("+")),
|
||||
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", "border rounded p-2 bg-white overflow-auto"),
|
||||
Raw(clickChartSVG(points.Get()))),
|
||||
),
|
||||
Div(Attr("class", "rounded-default border border-neutral-200 bg-white p-2 overflow-auto"),
|
||||
Raw(clickChartSVG(points.Get()))),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +1,36 @@
|
||||
#!/usr/bin/env bash
|
||||
# Pre-compile step: run the directive codegen, build the Go client to WebAssembly,
|
||||
# and stage the JS shim. Run the dev server instead (go run ./server) for hot
|
||||
# reload; this script is for a one-off/production-style build. Run from anywhere.
|
||||
# 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)"
|
||||
if [ -f "$GOROOT/lib/wasm/wasm_exec.js" ]; then
|
||||
cp "$GOROOT/lib/wasm/wasm_exec.js" wwwroot/wasm_exec.js # Go >= 1.24
|
||||
else
|
||||
cp "$GOROOT/misc/wasm/wasm_exec.js" wwwroot/wasm_exec.js # Go <= 1.23
|
||||
fi
|
||||
shim="$GOROOT/lib/wasm/wasm_exec.js" # Go >= 1.24
|
||||
[ -f "$shim" ] || shim="$GOROOT/misc/wasm/wasm_exec.js" # Go <= 1.23
|
||||
rm -f wwwroot/wasm_exec.js # GOROOT copy is read-only; remove before overwriting
|
||||
cp "$shim" wwwroot/wasm_exec.js
|
||||
chmod u+w wwwroot/wasm_exec.js
|
||||
|
||||
echo "==> Done. Run the server with: go run ./server"
|
||||
echo " then open http://localhost:8085"
|
||||
|
||||
14
go/cmd/examples/go-wasm-web/css/app.css
Normal file
14
go/cmd/examples/go-wasm-web/css/app.css
Normal file
@@ -0,0 +1,14 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* App-side design tokens the webui kit references (Tailwind v4 @theme). Brand
|
||||
values live with the app; the kit stays generic. */
|
||||
@theme {
|
||||
--radius-default: 0.375rem;
|
||||
|
||||
--color-primary: #4f46e5;
|
||||
--color-primary-hover: #4338ca;
|
||||
|
||||
--color-text-heading: #111827;
|
||||
--color-text-on-dark: #f9fafb;
|
||||
--color-text-on-dark-muted: #9ca3af;
|
||||
}
|
||||
Binary file not shown.
@@ -31,7 +31,7 @@ func main() {
|
||||
Addr: *addr,
|
||||
Dir: "./wwwroot",
|
||||
Watch: *watch,
|
||||
WatchDirs: []string{"app", "wasm", "../../../vdom", "../../../wasmruntime", "../../../rsc"}, // example + kjol engine
|
||||
WatchDirs: []string{"app", "wasm", "css", "../../../webui", "../../../vdom", "../../../wasmruntime", "../../../rsc"}, // example + kjol engine + kit
|
||||
Build: buildWasm,
|
||||
Render: render,
|
||||
Document: document,
|
||||
@@ -59,23 +59,37 @@ func document(inner string) string {
|
||||
<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="/bootstrap.min.css" />
|
||||
<link rel="stylesheet" href="/app.css" />
|
||||
</head>
|
||||
<body>
|
||||
<body class="bg-neutral-50 text-neutral-900 antialiased">
|
||||
<div id="app">` + inner + `</div>
|
||||
<script src="/wasm_exec.js"></script>
|
||||
<script src="/bootstrap.js"></script>
|
||||
<script src="/wasmboot.js"></script>
|
||||
</body>
|
||||
</html>`
|
||||
}
|
||||
|
||||
// buildWasm runs the directive codegen (kjol/cmd/wasmgen), then compiles
|
||||
// ./wasm to wwwroot/app.wasm. Returned combined output is shown in the browser
|
||||
// overlay on failure.
|
||||
// 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()
|
||||
|
||||
100
go/cmd/examples/go-wasm-web/wwwroot/app.css
Normal file
100
go/cmd/examples/go-wasm-web/wwwroot/app.css
Normal file
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user