initial port of the UI kit
This commit is contained in:
@@ -35,8 +35,9 @@ Language-first, **not** feature-first. Consequence: the **bundler is Go** and li
|
|||||||
|
|
||||||
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 the **gowasm** web-UI engine (`vdom`
|
l4g security snailmail validation bundler`, plus the **gowasm** web-UI engine (`vdom`
|
||||||
`wasmruntime` `rsc` `wasmdevserver` — author components in pure Go compiled to WebAssembly;
|
`wasmruntime` `rsc` `wasmdevserver`, and `webui` — a Tailwind-styled component kit ported
|
||||||
all stdlib-only), and `cmd/{bundle,migrate,loc,passgen,typecheck,wasmgen}`. A runnable
|
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
|
example lives in `cmd/examples/go-wasm-web` (its own nested module so its go-chart dep stays
|
||||||
out of kjol).
|
out of kjol).
|
||||||
|
|
||||||
|
|||||||
19
go/bundler/public_tailwind.go
Normal file
19
go/bundler/public_tailwind.go
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
package bundler
|
||||||
|
|
||||||
|
// CompileTailwind compiles a Tailwind v4 entry stylesheet with kjol's native
|
||||||
|
// engine, discovering utility candidates from the given source globs (relative
|
||||||
|
// to baseDir). The scanner is language-agnostic — it extracts candidate class
|
||||||
|
// tokens from any text file — so this works for markup authored in Go (e.g. the
|
||||||
|
// go-wasm-web example's webui components) just as well as .tsx/.html.
|
||||||
|
//
|
||||||
|
// entryCSS is the stylesheet source (typically `@import "tailwindcss";` plus an
|
||||||
|
// `@theme { … }` block). Returns minified CSS. This is the app-bundler engine
|
||||||
|
// (twCompile/scanSources) exposed for consumers that don't go through Build.
|
||||||
|
func CompileTailwind(entryCSS, baseDir string, sourceGlobs []string) (string, error) {
|
||||||
|
candidates := scanSources(baseDir, sourceGlobs)
|
||||||
|
compiled, _, err := twCompile(entryCSS, baseDir, candidates)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return m.String("text/css", compiled)
|
||||||
|
}
|
||||||
@@ -2,10 +2,11 @@
|
|||||||
|
|
||||||
A runnable example of kjol's **gowasm** engine: author UI **components in pure
|
A runnable example of kjol's **gowasm** engine: author UI **components in pure
|
||||||
Go**, compiled to **WebAssembly**, with **SSR + hydration**, **Next.js-style
|
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
|
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
|
(`kjol/go/{vdom,wasmruntime,rsc,wasmdevserver,webui}`); this directory is only the
|
||||||
that consumes them.
|
app that consumes them.
|
||||||
|
|
||||||
## Run it
|
## Run it
|
||||||
|
|
||||||
@@ -14,12 +15,16 @@ cd cmd/examples/go-wasm-web
|
|||||||
go run ./server # codegen + SSR + hot reload at http://localhost:8085
|
go run ./server # codegen + SSR + hot reload at http://localhost:8085
|
||||||
```
|
```
|
||||||
|
|
||||||
Open http://localhost:8085. `/` and `/about` use the light **public** layout,
|
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
|
`/chart`, `/server`, and **`/kit`** (a UI-kit "kitchen-sink" demo of the webui
|
||||||
browser hot-swaps the new wasm **without a full reload or a flash**, preserving
|
components) use the dark **app** layout. Edit any `.go` file and the browser
|
||||||
page state; a build failure shows the Go compiler output as an overlay.
|
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
|
## 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)
|
app/ the application — neutral, standalone functions (no central struct)
|
||||||
pages.go Deps + Shell + App/Public layouts + nav + Counter + pages
|
pages.go Deps + Shell + App/Public layouts + nav + Counter + pages
|
||||||
chart.go Chart page (go-chart, renders on both sides)
|
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)
|
server_counter.go //gowasm:server component (server-only; clicks-over-time chart)
|
||||||
*.gen.go GENERATED by kjol/cmd/wasmgen (routes, layout dispatch, stubs)
|
*.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)
|
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
|
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)
|
## How it maps onto the engine (top-level `kjol` packages)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
chart "github.com/wcharczuk/go-chart/v2"
|
chart "github.com/wcharczuk/go-chart/v2"
|
||||||
|
|
||||||
. "kjol/vdom"
|
. "kjol/vdom"
|
||||||
|
ui "kjol/webui"
|
||||||
)
|
)
|
||||||
|
|
||||||
var chartLabels = []string{"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"}
|
var chartLabels = []string{"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"}
|
||||||
@@ -64,17 +65,16 @@ func ChartPage(d Deps) func() *VNode {
|
|||||||
data := NewSignal(fixedChartData())
|
data := NewSignal(fixedChartData())
|
||||||
return func() *VNode {
|
return func() *VNode {
|
||||||
values := data.Get()
|
values := data.Get()
|
||||||
return Div(
|
return Div(Attr("class", "space-y-6"),
|
||||||
H2(Attr("class", "h4 mb-3"), Text("Charts — go-chart (SSR + hydrate)")),
|
Div(
|
||||||
P(Attr("class", "text-secondary"),
|
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.")),
|
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")),
|
ui.Button(ui.ButtonProps{Color: ui.ButtonPrimary, Text: "Shuffle data", OnClick: func() { data.Set(randomValues()) }}),
|
||||||
Div(Attr("class", "row"),
|
Div(Attr("class", "grid gap-4 lg:grid-cols-12"),
|
||||||
Div(Attr("class", "col-12 col-lg-7 mb-3"),
|
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", "border rounded p-2 bg-white 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))),
|
||||||
Div(Attr("class", "col-12 col-lg-5 mb-3"),
|
|
||||||
Div(Attr("class", "border rounded p-2 bg-white 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
|
// Package app holds the go-wasm-web example's pages and components as
|
||||||
// functions (no central App struct). It is platform-neutral, so the SAME code
|
// standalone, platform-neutral functions (SSR on the server, hydrate on the
|
||||||
// renders on the server (SSR) and hydrates on the client.
|
// 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>]
|
// //gowasm:page <path> [static] [layout=<name>] a route (static => SSR'd)
|
||||||
// marks a page factory as a route. `static`
|
// //gowasm:layout <name> a func(Deps, *VNode) *VNode wrapper
|
||||||
// pre-renders it on the server (SSR);
|
// //gowasm:server (see server_counter.go) a server component
|
||||||
// `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.
|
|
||||||
package app
|
package app
|
||||||
|
|
||||||
//go:generate go run kjol/cmd/wasmgen .
|
//go:generate go run kjol/cmd/wasmgen .
|
||||||
@@ -25,9 +15,10 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
. "kjol/vdom"
|
. "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 {
|
type Deps struct {
|
||||||
Path func() string
|
Path func() string
|
||||||
Navigate func(string)
|
Navigate func(string)
|
||||||
@@ -35,13 +26,11 @@ type Deps struct {
|
|||||||
|
|
||||||
func itoa(n int) string { return strconv.Itoa(n) }
|
func itoa(n int) string { return strconv.Itoa(n) }
|
||||||
|
|
||||||
// Layout wraps a page's rendered content with shared chrome (nav, footer, …).
|
// Layout wraps a page's content with shared chrome (declared with //gowasm:layout,
|
||||||
// Layouts are declared with //gowasm:layout and selected per route via a page's
|
// selected per route via `layout=`; the generated LayoutFor dispatches by name).
|
||||||
// `layout=` directive; the generated LayoutFor dispatches by name.
|
|
||||||
type Layout func(d Deps, content *VNode) *VNode
|
type Layout func(d Deps, content *VNode) *VNode
|
||||||
|
|
||||||
// Shell renders the current route's page inside its declared layout. `routes` is
|
// Shell renders the current route's page inside its declared layout.
|
||||||
// the generated route table; `d.Path()` selects both the page and its layout.
|
|
||||||
func Shell(d Deps, routes map[string]func() *VNode) *VNode {
|
func Shell(d Deps, routes map[string]func() *VNode) *VNode {
|
||||||
path := d.Path()
|
path := d.Path()
|
||||||
var content *VNode
|
var content *VNode
|
||||||
@@ -54,70 +43,71 @@ func Shell(d Deps, routes map[string]func() *VNode) *VNode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func notFound(path string) *VNode {
|
func notFound(path string) *VNode {
|
||||||
return Div(
|
return Div(Attr("class", "py-10"),
|
||||||
H2(Attr("class", "h4 mb-3"), Text("Page not found")),
|
H2(Attr("class", "text-xl font-semibold text-neutral-800 mb-2"), Text("Page not found")),
|
||||||
P(Attr("class", "text-secondary"), Text("No route matches "+path+".")),
|
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
|
//gowasm:layout public
|
||||||
func PublicLayout(d Deps, content *VNode) *VNode {
|
func PublicLayout(d Deps, content *VNode) *VNode {
|
||||||
return Div(
|
return Div(
|
||||||
Nav(Attr("class", "navbar navbar-expand bg-light border-bottom mb-4"),
|
Nav(Attr("class", "site-nav sticky top-0 z-10 border-b border-neutral-200 bg-white"),
|
||||||
Div(Attr("class", "container"),
|
Div(Attr("class", "mx-auto flex max-w-5xl items-center gap-2 px-4 py-3"),
|
||||||
A(Attr("class", "navbar-brand fw-bold"), Attr("href", "/"), navigate(d, "/"), Text("gowasm")),
|
A(Attr("class", "text-lg font-semibold tracking-tight text-text-heading no-underline"), Attr("href", "/"), navigate(d, "/"), Text("gowasm")),
|
||||||
Ul(Attr("class", "navbar-nav ms-auto align-items-center"),
|
Ul(Attr("class", "ml-auto flex items-center gap-1"),
|
||||||
navItem(d, "/", "Home"),
|
navItem(d, "/", "Home", false),
|
||||||
navItem(d, "/about", "About"),
|
navItem(d, "/about", "About", false),
|
||||||
Li(Attr("class", "nav-item ms-2"),
|
Li(Attr("class", "ml-2"),
|
||||||
A(Attr("class", "btn btn-sm btn-primary"), Attr("href", "/chart"),
|
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"),
|
||||||
navigate(d, "/chart"), Text("Open app →"))),
|
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,
|
content,
|
||||||
Footer(Attr("class", "text-secondary small border-top mt-5 pt-3"),
|
Footer(Attr("class", "mt-12 border-t border-neutral-200 pt-4 text-sm text-neutral-400"),
|
||||||
Text("gowasm public site — a tiny Blazor-like engine in Go.")),
|
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
|
//gowasm:layout app
|
||||||
func AppLayout(d Deps, content *VNode) *VNode {
|
func AppLayout(d Deps, content *VNode) *VNode {
|
||||||
return Div(
|
return Div(Attr("class", "min-h-screen"),
|
||||||
Nav(Attr("class", "navbar navbar-expand navbar-dark bg-dark mb-4"),
|
Nav(Attr("class", "app-nav border-b border-neutral-800 bg-neutral-900"),
|
||||||
Div(Attr("class", "container"),
|
Div(Attr("class", "mx-auto flex max-w-5xl items-center gap-2 px-4 py-3"),
|
||||||
A(Attr("class", "navbar-brand fw-bold"), Attr("href", "/chart"), navigate(d, "/chart"), Text("gowasm · app")),
|
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", "navbar-nav me-auto"),
|
Ul(Attr("class", "ml-4 flex items-center gap-1"),
|
||||||
navItem(d, "/chart", "Chart"),
|
navItem(d, "/chart", "Chart", true),
|
||||||
navItem(d, "/server", "Server")),
|
navItem(d, "/server", "Server", true),
|
||||||
Ul(Attr("class", "navbar-nav"),
|
navItem(d, "/kit", "UI Kit", true)),
|
||||||
navItem(d, "/", "Home")),
|
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.
|
// navItem is a nav link with an active state; dark switches to on-dark colors.
|
||||||
func navItem(d Deps, path, label string) *VNode {
|
func navItem(d Deps, path, label string, dark bool) *VNode {
|
||||||
cls := "nav-link"
|
active := d.Path() == path
|
||||||
if d.Path() == path {
|
var cls string
|
||||||
cls += " active"
|
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"),
|
return Li(A(Attr("class", cls+" no-underline"), Attr("href", path), navigate(d, path), Text(label)))
|
||||||
A(Attr("class", cls), Attr("href", path), navigate(d, path), Text(label)))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// navigate intercepts a link click for client-side SPA navigation. On the server
|
// navigate intercepts a link click for client-side SPA navigation (Navigate is
|
||||||
// Navigate is nil, so the anchor falls back to a normal navigation.
|
// nil on the server, so the anchor falls back to a normal navigation).
|
||||||
func navigate(d Deps, path string) Mod {
|
func navigate(d Deps, path string) Mod {
|
||||||
return OnEvent(EVENT_CLICK, func(e Event) {
|
return OnEvent(EVENT_CLICK, func(e Event) {
|
||||||
if d.Navigate != nil {
|
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.
|
// Counter is a presentational client component; state is owned by the caller.
|
||||||
func Counter(label string, count *Signal[int]) *VNode {
|
func Counter(label string, count *Signal[int]) *VNode {
|
||||||
return Div(Attr("class", "counter card mb-2"),
|
return Div(Attr("class", "counter flex items-center gap-3 rounded-default border border-neutral-200 bg-white px-4 py-3 shadow-xs"),
|
||||||
Div(Attr("class", "card-body py-2 d-flex align-items-center"),
|
Span(Attr("class", "font-medium text-neutral-700"), Text(label+": ")),
|
||||||
Span(Attr("class", "me-2 fw-semibold"), 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()))),
|
||||||
Strong(Attr("class", "badge text-bg-primary me-2"), Text(itoa(count.Get()))),
|
Div(Attr("class", "ml-auto flex gap-1"),
|
||||||
Div(Attr("class", "btn-group btn-group-sm ms-auto"),
|
ui.Button(ui.ButtonProps{Color: ui.ButtonSecondary, Small: true, Text: "−", OnClick: func() { count.Update(func(v int) int { return v - 1 }) }}),
|
||||||
Button(Attr("class", "btn btn-outline-secondary"),
|
ui.Button(ui.ButtonProps{Color: ui.ButtonPrimary, Small: true, Text: "+", OnClick: func() { count.Update(func(v int) int { return v + 1 }) }}),
|
||||||
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("+")),
|
|
||||||
)))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
//gowasm:page / static layout=public
|
//gowasm:page / static layout=public
|
||||||
func HomePage(d Deps) func() *VNode {
|
func HomePage(d Deps) func() *VNode {
|
||||||
a := NewSignal(0)
|
a := NewSignal(0)
|
||||||
b := NewSignal(0)
|
b := NewSignal(0)
|
||||||
|
dark := NewSignal(false)
|
||||||
return func() *VNode {
|
return func() *VNode {
|
||||||
return Div(
|
return Div(Attr("class", "space-y-8"),
|
||||||
H2(Attr("class", "h4 mb-3"), Text("Home — component composition")),
|
Div(
|
||||||
P(Attr("class", "text-secondary"), Text("Two counters; the total is derived across them. Server-rendered, then hydrated.")),
|
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("Apples", a),
|
||||||
Counter("Bananas", b),
|
Counter("Bananas", b),
|
||||||
Div(Attr("class", "alert alert-info d-flex justify-content-between align-items-center mt-3"),
|
),
|
||||||
|
ui.Alert(ui.AlertBlue, "",
|
||||||
Span(Text("Combined total: ")),
|
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
|
//gowasm:page /about static layout=public
|
||||||
func AboutPage(d Deps) func() *VNode {
|
func AboutPage(d Deps) func() *VNode {
|
||||||
return func() *VNode {
|
return func() *VNode {
|
||||||
return Div(
|
return Div(Attr("class", "space-y-6"),
|
||||||
H2(Attr("class", "h4 mb-3"), Text("About")),
|
H2(Attr("class", "text-2xl font-semibold tracking-tight text-text-heading"), Text("About")),
|
||||||
P(Attr("class", "lead"),
|
ui.Card("",
|
||||||
Text("Components are standalone functions; calling a server component looks "+
|
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 "+
|
"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 "+
|
"build-time codegen wire up the round-trip. Static routes are SSR'd; the rest "+
|
||||||
"render on the client.")),
|
"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
|
//gowasm:page /server layout=app
|
||||||
func ServerPage(d Deps) func() *VNode {
|
func ServerPage(d Deps) func() *VNode {
|
||||||
// ServerCounter is a server component — but calling it is just like calling
|
// ServerCounter is a server component — calling it is just like calling any
|
||||||
// any component. On the client this resolves to a generated stub that mounts
|
// component. On the client this resolves to a generated stub that mounts it
|
||||||
// it over /rsc; on the server it's the real function. (Not `static`: the
|
// over /rsc; on the server it's the real function.
|
||||||
// client renders the stub, which round-trips, so there's nothing stable to
|
|
||||||
// pre-render + hydrate.)
|
|
||||||
counter := ServerCounter()
|
counter := ServerCounter()
|
||||||
return func() *VNode {
|
return func() *VNode {
|
||||||
return Div(
|
return Div(Attr("class", "space-y-6"),
|
||||||
H2(Attr("class", "h4 mb-3"), Text("Server component")),
|
H2(Attr("class", "text-2xl font-semibold tracking-tight text-text-heading"), Text("Server component")),
|
||||||
P(Attr("class", "text-secondary"),
|
P(Attr("class", "text-neutral-500"),
|
||||||
Text("This counter runs on the server. Its state lives there; clicks round-trip "+
|
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 "+
|
"and the returned render merges into the DOM.")),
|
||||||
"client component.")),
|
|
||||||
counter(),
|
counter(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ func Routes(d Deps) map[string]func() *vdom.VNode {
|
|||||||
"/": HomePage(d),
|
"/": HomePage(d),
|
||||||
"/about": AboutPage(d),
|
"/about": AboutPage(d),
|
||||||
"/chart": ChartPage(d),
|
"/chart": ChartPage(d),
|
||||||
|
"/kit": KitPage(d),
|
||||||
"/server": ServerPage(d),
|
"/server": ServerPage(d),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -25,6 +26,7 @@ var RouteLayout = map[string]string{
|
|||||||
"/": "public",
|
"/": "public",
|
||||||
"/about": "public",
|
"/about": "public",
|
||||||
"/chart": "app",
|
"/chart": "app",
|
||||||
|
"/kit": "app",
|
||||||
"/server": "app",
|
"/server": "app",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
chart "github.com/wcharczuk/go-chart/v2"
|
chart "github.com/wcharczuk/go-chart/v2"
|
||||||
|
|
||||||
. "kjol/vdom"
|
. "kjol/vdom"
|
||||||
|
ui "kjol/webui"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ServerCounter is a SERVER component — note it's written exactly like a client
|
// 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()}))
|
points.Set(append(points.Get(), clickPoint{T: time.Now().UnixMilli(), V: count.Get()}))
|
||||||
}
|
}
|
||||||
return func() *VNode {
|
return func() *VNode {
|
||||||
return Div(Attr("class", "card"),
|
return ui.Card("",
|
||||||
Div(Attr("class", "card-body"),
|
Div(Attr("class", "flex items-center gap-2 mb-3"),
|
||||||
Div(Attr("class", "d-flex align-items-center gap-2 mb-2"),
|
Span(Attr("class", "text-neutral-700"), Text("Server counter: ")),
|
||||||
Span(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()))),
|
||||||
Strong(Attr("class", "badge text-bg-success fs-6"), Text(strconv.Itoa(count.Get()))),
|
Div(Attr("class", "ml-auto flex gap-1"),
|
||||||
Button(Attr("class", "btn btn-sm btn-outline-secondary"),
|
ui.Button(ui.ButtonProps{Color: ui.ButtonSecondary, Small: true, Text: "−", OnClick: func() { bump(-1) }}),
|
||||||
On(EVENT_CLICK, func() { bump(-1) }), Text("−")),
|
ui.Button(ui.ButtonProps{Color: ui.ButtonGreen, Small: true, Text: "+", OnClick: func() { bump(1) }}),
|
||||||
Button(Attr("class", "btn btn-sm btn-success"),
|
|
||||||
On(EVENT_CLICK, func() { bump(1) }), Text("+")),
|
|
||||||
),
|
),
|
||||||
Div(Attr("class", "border rounded p-2 bg-white overflow-auto"),
|
),
|
||||||
|
Div(Attr("class", "rounded-default border border-neutral-200 bg-white p-2 overflow-auto"),
|
||||||
Raw(clickChartSVG(points.Get()))),
|
Raw(clickChartSVG(points.Get()))),
|
||||||
),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,36 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Pre-compile step: run the directive codegen, build the Go client to WebAssembly,
|
# Pre-compile step: directive codegen, Tailwind CSS, WebAssembly build, JS shim.
|
||||||
# and stage the JS shim. Run the dev server instead (go run ./server) for hot
|
# Run the dev server instead (go run ./server) for hot reload; this is for a
|
||||||
# reload; this script is for a one-off/production-style build. Run from anywhere.
|
# one-off/production-style build. Run from anywhere.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
cd "$(dirname "$0")"
|
cd "$(dirname "$0")"
|
||||||
|
KJOL_GO="$(cd ../../.. && pwd)"
|
||||||
|
|
||||||
echo "==> Generating directive glue (//gowasm:page, //gowasm:layout, //gowasm:server)"
|
echo "==> Generating directive glue (//gowasm:page, //gowasm:layout, //gowasm:server)"
|
||||||
go run kjol/cmd/wasmgen ./app
|
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)"
|
echo "==> Compiling ./wasm to wwwroot/app.wasm (GOOS=js GOARCH=wasm)"
|
||||||
GOOS=js GOARCH=wasm go build -o wwwroot/app.wasm ./wasm
|
GOOS=js GOARCH=wasm go build -o wwwroot/app.wasm ./wasm
|
||||||
|
|
||||||
echo "==> Copying Go's wasm_exec.js shim into wwwroot/"
|
echo "==> Copying Go's wasm_exec.js shim into wwwroot/"
|
||||||
GOROOT="$(go env GOROOT)"
|
GOROOT="$(go env GOROOT)"
|
||||||
if [ -f "$GOROOT/lib/wasm/wasm_exec.js" ]; then
|
shim="$GOROOT/lib/wasm/wasm_exec.js" # Go >= 1.24
|
||||||
cp "$GOROOT/lib/wasm/wasm_exec.js" wwwroot/wasm_exec.js # Go >= 1.24
|
[ -f "$shim" ] || shim="$GOROOT/misc/wasm/wasm_exec.js" # Go <= 1.23
|
||||||
else
|
rm -f wwwroot/wasm_exec.js # GOROOT copy is read-only; remove before overwriting
|
||||||
cp "$GOROOT/misc/wasm/wasm_exec.js" wwwroot/wasm_exec.js # Go <= 1.23
|
cp "$shim" wwwroot/wasm_exec.js
|
||||||
fi
|
chmod u+w wwwroot/wasm_exec.js
|
||||||
|
|
||||||
echo "==> Done. Run the server with: go run ./server"
|
echo "==> Done. Run the server with: go run ./server"
|
||||||
echo " then open http://localhost:8085"
|
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,
|
Addr: *addr,
|
||||||
Dir: "./wwwroot",
|
Dir: "./wwwroot",
|
||||||
Watch: *watch,
|
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,
|
Build: buildWasm,
|
||||||
Render: render,
|
Render: render,
|
||||||
Document: document,
|
Document: document,
|
||||||
@@ -59,23 +59,37 @@ func document(inner string) string {
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>gowasm — a tiny Blazor-like engine</title>
|
<title>gowasm — a tiny Blazor-like engine</title>
|
||||||
<link rel="stylesheet" href="/bootstrap.min.css" />
|
<link rel="stylesheet" href="/app.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body class="bg-neutral-50 text-neutral-900 antialiased">
|
||||||
<div id="app">` + inner + `</div>
|
<div id="app">` + inner + `</div>
|
||||||
<script src="/wasm_exec.js"></script>
|
<script src="/wasm_exec.js"></script>
|
||||||
<script src="/bootstrap.js"></script>
|
<script src="/wasmboot.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>`
|
</html>`
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildWasm runs the directive codegen (kjol/cmd/wasmgen), then compiles
|
// buildWasm runs the directive codegen (kjol/cmd/wasmgen), compiles the Tailwind
|
||||||
// ./wasm to wwwroot/app.wasm. Returned combined output is shown in the browser
|
// CSS (kjol/cmd/twcss, scanning the Go markup + webui kit), then compiles ./wasm
|
||||||
// overlay on failure.
|
// to wwwroot/app.wasm. Returned combined output is shown in the browser overlay
|
||||||
|
// on failure.
|
||||||
func buildWasm() ([]byte, error) {
|
func buildWasm() ([]byte, error) {
|
||||||
if out, err := exec.Command("go", "run", "kjol/cmd/wasmgen", "./app").CombinedOutput(); err != nil {
|
if out, err := exec.Command("go", "run", "kjol/cmd/wasmgen", "./app").CombinedOutput(); err != nil {
|
||||||
return out, err
|
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 := exec.Command("go", "build", "-o", filepath.Join("wwwroot", "app.wasm"), "./wasm")
|
||||||
cmd.Env = append(os.Environ(), "GOOS=js", "GOARCH=wasm")
|
cmd.Env = append(os.Environ(), "GOOS=js", "GOARCH=wasm")
|
||||||
return cmd.CombinedOutput()
|
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
45
go/cmd/twcss/main.go
Normal file
45
go/cmd/twcss/main.go
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
// Command twcss compiles a Tailwind v4 stylesheet with kjol's native engine,
|
||||||
|
// scanning explicit content globs for utility candidates. Unlike the app bundler
|
||||||
|
// (which is wired to the frontend tree) it takes the entry, output, and content
|
||||||
|
// globs as flags/args, so it works for markup authored in any language — used by
|
||||||
|
// the go-wasm-web example, whose UI is written in Go.
|
||||||
|
//
|
||||||
|
// Usage (globs are relative to -base; pass "**" for a recursive walk):
|
||||||
|
//
|
||||||
|
// twcss -entry css/app.css -out wwwroot/app.css -base . 'webui/**/*.go' 'app/**/*.go'
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"kjol/bundler"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
entry := flag.String("entry", "", "path to the Tailwind entry stylesheet")
|
||||||
|
out := flag.String("out", "", "output CSS path")
|
||||||
|
base := flag.String("base", ".", "base dir the content globs are relative to")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
if *entry == "" || *out == "" {
|
||||||
|
fmt.Fprintln(os.Stderr, "twcss: -entry and -out are required")
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
src, err := os.ReadFile(*entry)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "twcss:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
css, err := bundler.CompileTailwind(string(src), *base, flag.Args())
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "twcss:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(*out, []byte(css), 0o644); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "twcss:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Printf("twcss: wrote %s (%d bytes)\n", *out, len(css))
|
||||||
|
}
|
||||||
@@ -49,7 +49,11 @@ func El(tag string, mods ...Mod) *VNode {
|
|||||||
// Text builds a text VNode.
|
// Text builds a text VNode.
|
||||||
func Text(s string) *VNode { return &VNode{Text: s} }
|
func Text(s string) *VNode { return &VNode{Text: s} }
|
||||||
|
|
||||||
func (n *VNode) apply(parent *VNode) { parent.Children = append(parent.Children, n) }
|
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 }
|
type attrMod struct{ k, v string }
|
||||||
|
|
||||||
|
|||||||
@@ -111,6 +111,13 @@ func patch(parent js.Value, o, x *vdom.VNode) {
|
|||||||
default:
|
default:
|
||||||
x.Runtime = o.Runtime // adopt dom + listeners
|
x.Runtime = o.Runtime // adopt dom + listeners
|
||||||
dom := rt(x).dom
|
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.Tag == "" {
|
||||||
if x.Text != o.Text {
|
if x.Text != o.Text {
|
||||||
dom.Set("nodeValue", x.Text)
|
dom.Set("nodeValue", x.Text)
|
||||||
|
|||||||
57
go/webui/README.md
Normal file
57
go/webui/README.md
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# webui — Go/WebAssembly component kit
|
||||||
|
|
||||||
|
A Go port of kjol's Solid.js component kit (`web/kit`) for the **gowasm** engine.
|
||||||
|
Components are neutral `*vdom.VNode` builders (SSR on the server, hydrate on the
|
||||||
|
client) styled with **Tailwind** utility classes. Import as `kjol/webui`.
|
||||||
|
|
||||||
|
```go
|
||||||
|
import ui "kjol/webui"
|
||||||
|
|
||||||
|
ui.Button(ui.ButtonProps{Color: ui.ButtonPrimary, Text: "Save", OnClick: save})
|
||||||
|
ui.Alert(ui.AlertGreen, "Done", vdom.Text("Saved."))
|
||||||
|
ui.ToggleSwitch(on, func(v bool){ on = v }, "Notifications", "", false, "")
|
||||||
|
```
|
||||||
|
|
||||||
|
See the runnable **`/kit`** demo page in `cmd/examples/go-wasm-web` (app nav → "UI Kit").
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- A TSX `function Foo(props)` → `func Foo(p FooProps, children ...*vdom.VNode) *vdom.VNode`
|
||||||
|
(simple components take positional params). Reactive accessors collapse: pass plain
|
||||||
|
current values (read a signal at the call site); state changes come back via `OnChange`
|
||||||
|
callbacks (the whole tree re-renders and reconciles).
|
||||||
|
- Tailwind classes are copied verbatim so `kjol/cmd/twcss` (scanning these `.go` files)
|
||||||
|
emits the CSS. Custom tokens (`rounded-default`, `bg-primary`, `text-text-heading`, …)
|
||||||
|
come from the app's `@theme` block.
|
||||||
|
- Icons via `Icon(name, size, class)`; the registry ships a small default set, apps add
|
||||||
|
more with `RegisterIcon`. Unregistered names render an empty (correctly-sized) box.
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
- **Layout/content:** `Card*`, `BorderCard`, `CardHeader`, `PageHeader`, `PageContainer`,
|
||||||
|
`Breadcrumbs`, `Divider`, `CodeBox`, `Loader`, `Sidebar*`, `Icon*`.
|
||||||
|
- **Controls:** `Button`, `ButtonLink`, `SegmentedButtons`, `BackLink`, `Badge`,
|
||||||
|
`ToggleSwitch`, and the `Form*` family (`FormInput`, `FormTextarea`, `FormSelect`,
|
||||||
|
`FormLabel`, `FormFieldset`, `FormNumberInput`/`FormCurrencyInput`/`FormEmailInput`/… ).
|
||||||
|
- **Disclosure/nav:** `Alert`, `TabGroup`, `CrmTabGroup`, `Accordion`/`SingleAccordion`,
|
||||||
|
`Menu`*, `Submenu`, `EnvBadge`, `RemoteUpdateFlash`.
|
||||||
|
- **Overlays/floating:** `Modal`/`ConfirmModal`/`WizardModal`, `Toast*`, `Tooltip`/
|
||||||
|
`HoverTooltip`, `Popover`/`HoverPopover`, `Floating*`.
|
||||||
|
- **Data:** `PrettyTable`, `AutoTable`, `CellGrid`, `ReactiveChart`, `Calendar`,
|
||||||
|
`DatePicker`, `FuzzyMatch*` (+ pure matchers), `Tutorial`.
|
||||||
|
- **Pure logic (no vdom):** `formatters.go` (`FormatPhoneNumber`, `FormatDate`, …) and
|
||||||
|
`validation.go` (`IsEmailValid`, `CreateValidation`, …).
|
||||||
|
|
||||||
|
## Runtime limitations (what the TSX did that this can't)
|
||||||
|
|
||||||
|
The neutral runtime has **no floating-ui, portals, refs, element measurement, focus
|
||||||
|
traps, or timers**. Components that relied on those are ported as **structure +
|
||||||
|
Tailwind + signal/event wiring**, with open/selection state lifted to props +
|
||||||
|
callbacks and positioning approximated with static Tailwind. Each such gap is marked
|
||||||
|
with a `// NOTE:` in the component's file. Concretely: dropdown/menu/tooltip/modal
|
||||||
|
positioning is static (not computed); outside-click / Escape / hover-delay dismissal,
|
||||||
|
auto-dismiss timers, and enter/exit animations are dropped (caller-driven); `AutoTable`
|
||||||
|
omits virtual scrolling, column resize/reorder, inline editing, and the formula engine;
|
||||||
|
`ReactiveChart` renders only its container (the example draws charts server-side with
|
||||||
|
go-chart); a few `Form*` controls that needed canvas/async (`FormSignaturePad`,
|
||||||
|
`FormAsyncCombobox`) are omitted. Everything compiles on native (SSR) and js/wasm.
|
||||||
131
go/webui/accordion.go
Normal file
131
go/webui/accordion.go
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
package webui
|
||||||
|
|
||||||
|
import "kjol/vdom"
|
||||||
|
|
||||||
|
// Port of web/kit/Accordion.tsx.
|
||||||
|
//
|
||||||
|
// NOTE: The TSX AccordionItem kept its open state in an internal signal (seeded
|
||||||
|
// by startOpen, synced from an optional isOpen prop via createEffect). Per the
|
||||||
|
// porting guide, selection state collapses to a plain value + callback: IsOpen
|
||||||
|
// is a plain bool and OnToggle fires on click (the caller flips its own state).
|
||||||
|
// The TSX startOpen prop collapses into the caller-supplied initial IsOpen /
|
||||||
|
// open[] / openIndex.
|
||||||
|
|
||||||
|
const accordionRootCls = "ui-accordion border border-neutral-200 rounded-default overflow-hidden"
|
||||||
|
const accordionItemCls = "ui-accordion-item border-b border-neutral-200 last:border-b-0"
|
||||||
|
const accordionTriggerCls = "ui-accordion-trigger flex items-center justify-between w-full py-3 px-4 text-left font-medium text-neutral-900 bg-neutral-50 cursor-pointer border-none transition-colors hover:bg-neutral-100 active:bg-neutral-200 focus:outline-hidden disabled:text-neutral-400 disabled:cursor-not-allowed disabled:bg-neutral-50"
|
||||||
|
const accordionTitleCls = "ui-accordion-title flex-1"
|
||||||
|
const accordionContentCls = "ui-accordion-content px-4 pb-4 text-neutral-700"
|
||||||
|
|
||||||
|
func accordionIconCls(open bool) string {
|
||||||
|
c := "ui-accordion-icon text-neutral-500 leading-none transition-transform duration-200"
|
||||||
|
if open {
|
||||||
|
c += " rotate-180"
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// AccordionItemProps configures a single AccordionItem. IsOpen is the current
|
||||||
|
// open state; OnToggle fires when the (enabled) trigger is clicked.
|
||||||
|
type AccordionItemProps struct {
|
||||||
|
IsOpen bool
|
||||||
|
Disabled bool
|
||||||
|
Title string
|
||||||
|
OnToggle func()
|
||||||
|
}
|
||||||
|
|
||||||
|
// AccordionItem is one collapsible row: a trigger button plus its content
|
||||||
|
// (children), shown when open and not disabled.
|
||||||
|
func AccordionItem(p AccordionItemProps, children ...*vdom.VNode) *vdom.VNode {
|
||||||
|
ariaExpanded := "false"
|
||||||
|
if p.IsOpen {
|
||||||
|
ariaExpanded = "true"
|
||||||
|
}
|
||||||
|
trigger := []vdom.Mod{
|
||||||
|
vdom.Attr("type", "button"),
|
||||||
|
vdom.Attr("class", accordionTriggerCls),
|
||||||
|
vdom.Attr("aria-expanded", ariaExpanded),
|
||||||
|
vdom.On(vdom.EVENT_CLICK, func() {
|
||||||
|
if p.Disabled {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if p.OnToggle != nil {
|
||||||
|
p.OnToggle()
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
vdom.El("span", vdom.Attr("class", accordionTitleCls), vdom.Text(p.Title)),
|
||||||
|
}
|
||||||
|
if p.Disabled {
|
||||||
|
trigger = append(trigger, vdom.Attr("disabled", "disabled"))
|
||||||
|
} else {
|
||||||
|
chev := "chevron-down"
|
||||||
|
if p.IsOpen {
|
||||||
|
chev = "chevron-up"
|
||||||
|
}
|
||||||
|
trigger = append(trigger, vdom.El("span",
|
||||||
|
vdom.Attr("class", accordionIconCls(p.IsOpen)),
|
||||||
|
Icon(chev, 18, ""),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
mods := []vdom.Mod{vdom.Attr("class", accordionItemCls), vdom.El("button", trigger...)}
|
||||||
|
if p.IsOpen && !p.Disabled {
|
||||||
|
mods = append(mods, vdom.El("div", kids([]vdom.Mod{vdom.Attr("class", accordionContentCls)}, children)...))
|
||||||
|
}
|
||||||
|
return vdom.El("div", mods...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AccordionItemData is one entry for Accordion / SingleAccordion.
|
||||||
|
type AccordionItemData struct {
|
||||||
|
Title string
|
||||||
|
Content *vdom.VNode
|
||||||
|
Disabled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accordion renders independently-collapsible items. open[i] is item i's open
|
||||||
|
// state (missing entries are closed); clicking item i calls onToggle(i) — the
|
||||||
|
// caller flips open[i].
|
||||||
|
func Accordion(items []AccordionItemData, open []bool, onToggle func(int)) *vdom.VNode {
|
||||||
|
mods := []vdom.Mod{vdom.Attr("class", accordionRootCls)}
|
||||||
|
for i, item := range items {
|
||||||
|
idx := i
|
||||||
|
isOpen := i < len(open) && open[i]
|
||||||
|
mods = append(mods, AccordionItem(AccordionItemProps{
|
||||||
|
IsOpen: isOpen,
|
||||||
|
Disabled: item.Disabled,
|
||||||
|
Title: item.Title,
|
||||||
|
OnToggle: func() {
|
||||||
|
if onToggle != nil {
|
||||||
|
onToggle(idx)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}, item.Content))
|
||||||
|
}
|
||||||
|
return vdom.El("div", mods...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SingleAccordion renders items where at most one is open. openIndex is the open
|
||||||
|
// item (-1 = none); clicking an item calls onChange with the new open index —
|
||||||
|
// the clicked index, or -1 if that item was already open.
|
||||||
|
func SingleAccordion(items []AccordionItemData, openIndex int, onChange func(int)) *vdom.VNode {
|
||||||
|
mods := []vdom.Mod{vdom.Attr("class", accordionRootCls)}
|
||||||
|
for i, item := range items {
|
||||||
|
idx := i
|
||||||
|
mods = append(mods, AccordionItem(AccordionItemProps{
|
||||||
|
IsOpen: openIndex == i,
|
||||||
|
Disabled: item.Disabled,
|
||||||
|
Title: item.Title,
|
||||||
|
OnToggle: func() {
|
||||||
|
if onChange == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if openIndex == idx {
|
||||||
|
onChange(-1)
|
||||||
|
} else {
|
||||||
|
onChange(idx)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}, item.Content))
|
||||||
|
}
|
||||||
|
return vdom.El("div", mods...)
|
||||||
|
}
|
||||||
41
go/webui/alerts.go
Normal file
41
go/webui/alerts.go
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
package webui
|
||||||
|
|
||||||
|
import "kjol/vdom"
|
||||||
|
|
||||||
|
// Port of web/kit/Alerts.tsx. The TSX exports one component per color
|
||||||
|
// (AlertWhite, AlertBlue, …); here that's Alert(color, header, children).
|
||||||
|
|
||||||
|
const (
|
||||||
|
AlertWhite = "white"
|
||||||
|
AlertGray = "gray"
|
||||||
|
AlertBlue = "blue"
|
||||||
|
AlertGreen = "green"
|
||||||
|
AlertRed = "red"
|
||||||
|
AlertYellow = "yellow"
|
||||||
|
)
|
||||||
|
|
||||||
|
const alertBase = "p-4 rounded-default shadow-xs border"
|
||||||
|
|
||||||
|
var alertColors = map[string]string{
|
||||||
|
"white": "bg-white border-neutral-100",
|
||||||
|
"gray": "bg-neutral-50 border-neutral-200",
|
||||||
|
"blue": "bg-sky-50 border-sky-200",
|
||||||
|
"green": "bg-green-50 border-green-200",
|
||||||
|
"red": "bg-red-50 border-red-200",
|
||||||
|
"yellow": "bg-yellow-50 border-yellow-200",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alert renders a colored callout. header is optional (rendered as a bold title
|
||||||
|
// above the body); children form the body.
|
||||||
|
func Alert(color, header string, children ...*vdom.VNode) *vdom.VNode {
|
||||||
|
cc := alertColors[color]
|
||||||
|
if cc == "" {
|
||||||
|
cc = alertColors["white"]
|
||||||
|
}
|
||||||
|
mods := []vdom.Mod{vdom.Attr("class", cx(alertBase, cc))}
|
||||||
|
if header != "" {
|
||||||
|
mods = append(mods, vdom.El("h3", vdom.Attr("class", "font-semibold mb-2"), vdom.Text(header)))
|
||||||
|
}
|
||||||
|
mods = append(mods, vdom.El("p", kids([]vdom.Mod{vdom.Attr("class", "text-sm")}, children)...))
|
||||||
|
return vdom.El("div", mods...)
|
||||||
|
}
|
||||||
601
go/webui/autotable.go
Normal file
601
go/webui/autotable.go
Normal file
@@ -0,0 +1,601 @@
|
|||||||
|
// Port of web/kit/AutoTable.tsx.
|
||||||
|
//
|
||||||
|
// NOTE: AutoTable.tsx is ~4200 LOC and heavily coupled to the browser. This is a
|
||||||
|
// faithful, COMPILING *core* — the column model, the header/body/pagination
|
||||||
|
// render shell, and the Tailwind styling — not a behavioral clone. The following
|
||||||
|
// substantial features of the TSX are INTENTIONALLY OUT OF SCOPE here because
|
||||||
|
// they depend on DOM measurement, refs, timers, portals, or floating-ui, none of
|
||||||
|
// which exist in the neutral vdom runtime:
|
||||||
|
//
|
||||||
|
// - Virtual scrolling / windowed row rendering (rows are rendered eagerly).
|
||||||
|
// - Column drag-to-reorder and drag-to-resize (getBoundingClientRect, refs,
|
||||||
|
// mousemove tracking) — replaced by a static per-column WidthClass.
|
||||||
|
// - Computed/pinned table sizing (pinnedTableWidth measurement) — replaced by
|
||||||
|
// table-fixed + static WidthClass.
|
||||||
|
// - Runtime-editable calculated columns and footer summary rows, the whole
|
||||||
|
// Excel-style formula engine (tokenize/compile/highlight), and their popover
|
||||||
|
// editors (Popover/Menu/floating-ui).
|
||||||
|
// - CSV and PDF export (pdf-lib / pdfjs-dist), and the export/customize popover.
|
||||||
|
// - Remote data fetching (authFetch), remoteFiltering, refreshSignal.
|
||||||
|
// - Search/filter toolbar, quick-date presets, column-visibility toggles, the
|
||||||
|
// search-aside card, and localStorage persistence of order/width/columns.
|
||||||
|
// - Accordion expand/collapse rows and row-highlight auto-paging.
|
||||||
|
// - Client-side sorting/filtering/pagination logic (compareRowsGeneric,
|
||||||
|
// processDataLocally): sort state and pagination are surfaced here as plain
|
||||||
|
// value props + callbacks; the caller owns the actual sort/page computation.
|
||||||
|
//
|
||||||
|
// What IS ported: the AutoTableColumn model (header text, alignment, width class,
|
||||||
|
// sortable + a cell render func), the AutoTable render shell (sticky header,
|
||||||
|
// body rows, loading skeletons, error/empty states, display-only pagination),
|
||||||
|
// the verbatim Tailwind class constants/maps, and sort/pagination as props +
|
||||||
|
// callbacks. Reactive accessors collapse to plain values per the port guide.
|
||||||
|
package webui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"kjol/vdom"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ColumnPosition is a cell/header alignment (mirrors the TSX 0|1|2 union).
|
||||||
|
type ColumnPosition int
|
||||||
|
|
||||||
|
const (
|
||||||
|
COL_POS_LEFT ColumnPosition = 0
|
||||||
|
COL_POS_RIGHT ColumnPosition = 1
|
||||||
|
COL_POS_CENTER ColumnPosition = 2
|
||||||
|
)
|
||||||
|
|
||||||
|
// AutoTableHeaderColor selects the header/body color scheme.
|
||||||
|
type AutoTableHeaderColor int
|
||||||
|
|
||||||
|
const (
|
||||||
|
AUTOTABLE_HEADER_COLOR_DEFAULT AutoTableHeaderColor = 0
|
||||||
|
AUTOTABLE_HEADER_COLOR_BLUE AutoTableHeaderColor = 1
|
||||||
|
AUTOTABLE_HEADER_COLOR_GREEN AutoTableHeaderColor = 2
|
||||||
|
AUTOTABLE_HEADER_COLOR_GRAY AutoTableHeaderColor = 3
|
||||||
|
AUTOTABLE_HEADER_COLOR_DARK_BLUE AutoTableHeaderColor = 4
|
||||||
|
)
|
||||||
|
|
||||||
|
// AutoTableSize selects header/body/pagination density.
|
||||||
|
type AutoTableSize int
|
||||||
|
|
||||||
|
const (
|
||||||
|
AUTOTABLE_SIZE_DEFAULT AutoTableSize = 0
|
||||||
|
AUTOTABLE_SIZE_COMPACT AutoTableSize = 1
|
||||||
|
AUTOTABLE_SIZE_SUPERCOMPACT AutoTableSize = 2
|
||||||
|
)
|
||||||
|
|
||||||
|
// -- Tailwind class maps (copied verbatim from AutoTable.tsx) ---------------
|
||||||
|
|
||||||
|
// HEADER_COLOR_CLS is the background + text color per header color.
|
||||||
|
var HEADER_COLOR_CLS = map[AutoTableHeaderColor]string{
|
||||||
|
AUTOTABLE_HEADER_COLOR_DEFAULT: "bg-neutral-50",
|
||||||
|
AUTOTABLE_HEADER_COLOR_BLUE: "bg-sky-700 text-white",
|
||||||
|
AUTOTABLE_HEADER_COLOR_GREEN: "bg-green-700 text-white",
|
||||||
|
AUTOTABLE_HEADER_COLOR_GRAY: "bg-neutral-600 text-white",
|
||||||
|
AUTOTABLE_HEADER_COLOR_DARK_BLUE: "bg-sky-900 text-white",
|
||||||
|
}
|
||||||
|
|
||||||
|
// atHeaderSortHoverCls is the sortable-hover override per color.
|
||||||
|
var atHeaderSortHoverCls = map[AutoTableHeaderColor]string{
|
||||||
|
AUTOTABLE_HEADER_COLOR_DEFAULT: "hover:bg-neutral-300",
|
||||||
|
AUTOTABLE_HEADER_COLOR_BLUE: "hover:bg-sky-500",
|
||||||
|
AUTOTABLE_HEADER_COLOR_GREEN: "hover:bg-green-800",
|
||||||
|
AUTOTABLE_HEADER_COLOR_GRAY: "hover:bg-neutral-500",
|
||||||
|
AUTOTABLE_HEADER_COLOR_DARK_BLUE: "hover:bg-sky-800",
|
||||||
|
}
|
||||||
|
|
||||||
|
// HEADER_TEXT_CLS is the header text weight/case per color.
|
||||||
|
var HEADER_TEXT_CLS = map[AutoTableHeaderColor]string{
|
||||||
|
AUTOTABLE_HEADER_COLOR_DEFAULT: "font-bold uppercase tracking-wider",
|
||||||
|
AUTOTABLE_HEADER_COLOR_BLUE: "font-semibold",
|
||||||
|
AUTOTABLE_HEADER_COLOR_GREEN: "font-semibold",
|
||||||
|
AUTOTABLE_HEADER_COLOR_GRAY: "font-semibold",
|
||||||
|
AUTOTABLE_HEADER_COLOR_DARK_BLUE: "font-semibold",
|
||||||
|
}
|
||||||
|
|
||||||
|
// atHeaderSortIconCls is the sort-icon color (matches header text) per color.
|
||||||
|
var atHeaderSortIconCls = map[AutoTableHeaderColor]string{
|
||||||
|
AUTOTABLE_HEADER_COLOR_DEFAULT: "text-black",
|
||||||
|
AUTOTABLE_HEADER_COLOR_BLUE: "text-white",
|
||||||
|
AUTOTABLE_HEADER_COLOR_GREEN: "text-white",
|
||||||
|
AUTOTABLE_HEADER_COLOR_GRAY: "text-white",
|
||||||
|
AUTOTABLE_HEADER_COLOR_DARK_BLUE: "text-white",
|
||||||
|
}
|
||||||
|
|
||||||
|
// HEADER_PADDING_CLS is the th padding per table size.
|
||||||
|
var HEADER_PADDING_CLS = map[AutoTableSize]string{
|
||||||
|
AUTOTABLE_SIZE_DEFAULT: "p-4 text-sm",
|
||||||
|
AUTOTABLE_SIZE_COMPACT: "py-1.5 px-2 text-sm",
|
||||||
|
AUTOTABLE_SIZE_SUPERCOMPACT: "py-1 px-2 text-xs",
|
||||||
|
}
|
||||||
|
|
||||||
|
// BODY_PADDING_CLS is the body cell padding per table size (applied via [&_td]:).
|
||||||
|
var BODY_PADDING_CLS = map[AutoTableSize]string{
|
||||||
|
AUTOTABLE_SIZE_DEFAULT: "text-sm [&_td]:p-4",
|
||||||
|
AUTOTABLE_SIZE_COMPACT: "text-sm [&_td]:py-1 [&_td]:px-2",
|
||||||
|
AUTOTABLE_SIZE_SUPERCOMPACT: "text-xs [&_td]:py-0.5 [&_td]:px-2",
|
||||||
|
}
|
||||||
|
|
||||||
|
// atPaginationPaddingCls is the pagination bar padding per table size.
|
||||||
|
var atPaginationPaddingCls = map[AutoTableSize]string{
|
||||||
|
AUTOTABLE_SIZE_DEFAULT: "py-3 px-4",
|
||||||
|
AUTOTABLE_SIZE_COMPACT: "py-1 px-4",
|
||||||
|
AUTOTABLE_SIZE_SUPERCOMPACT: "py-1 px-4",
|
||||||
|
}
|
||||||
|
|
||||||
|
// atRowHoverCls is the body-row hover background per color (when hover is on).
|
||||||
|
var atRowHoverCls = map[AutoTableHeaderColor]string{
|
||||||
|
AUTOTABLE_HEADER_COLOR_DEFAULT: "hover:bg-neutral-200",
|
||||||
|
AUTOTABLE_HEADER_COLOR_BLUE: "hover:bg-sky-100",
|
||||||
|
AUTOTABLE_HEADER_COLOR_GREEN: "hover:bg-green-100",
|
||||||
|
AUTOTABLE_HEADER_COLOR_GRAY: "hover:bg-neutral-200",
|
||||||
|
AUTOTABLE_HEADER_COLOR_DARK_BLUE: "hover:bg-sky-100",
|
||||||
|
}
|
||||||
|
|
||||||
|
// POS_CLS is the text alignment per ColumnPosition.
|
||||||
|
var POS_CLS = map[ColumnPosition]string{
|
||||||
|
COL_POS_LEFT: "text-left",
|
||||||
|
COL_POS_RIGHT: "text-right",
|
||||||
|
COL_POS_CENTER: "text-center",
|
||||||
|
}
|
||||||
|
|
||||||
|
// HEADER_INNER_POS is the header inner flex direction per position.
|
||||||
|
var HEADER_INNER_POS = map[ColumnPosition]string{
|
||||||
|
COL_POS_LEFT: "",
|
||||||
|
COL_POS_RIGHT: "flex-row-reverse",
|
||||||
|
COL_POS_CENTER: "justify-center",
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Class string constants for parts that don't vary by config ------------
|
||||||
|
const (
|
||||||
|
TBL_CONTAINER = "relative flex flex-col w-full h-full bg-white rounded-default overflow-hidden"
|
||||||
|
TBL_WRAPPER = "overflow-x-auto w-full"
|
||||||
|
TBL_BASE = "min-w-full"
|
||||||
|
|
||||||
|
HEADER_CONTENT = "transition-transform duration-150 ease-in-out"
|
||||||
|
HEADER_INNER_BASE = "flex justify-between gap-2 items-center"
|
||||||
|
|
||||||
|
atSortIconWrap = "leading-none shrink-0 opacity-50"
|
||||||
|
|
||||||
|
atSkeleton = "h-4 bg-neutral-200 rounded-default animate-pulse"
|
||||||
|
atErrorCell = "text-center text-red-600"
|
||||||
|
atEmptyCell = "text-center text-neutral-500"
|
||||||
|
|
||||||
|
atPaginationBase = "flex justify-between items-center border-t border-neutral-300"
|
||||||
|
atPaginationInfo = "hidden sm:flex items-center text-sm text-neutral-500"
|
||||||
|
atPaginationControls = "flex items-center"
|
||||||
|
atPaginationLabel = "hidden sm:block text-sm text-neutral-500 mr-2"
|
||||||
|
atPaginationPage = "text-sm text-neutral-500 px-3"
|
||||||
|
atPaginationBtn = "p-1 min-h-9 text-sm font-normal leading-none bg-transparent border-0 cursor-pointer hover:bg-neutral-100 disabled:text-neutral-300 disabled:cursor-not-allowed disabled:hover:bg-transparent"
|
||||||
|
|
||||||
|
// thead sticky classes are the STATIC replacement for the TSX's JS-driven
|
||||||
|
// header pinning (transform tracking on scroll). See file-level NOTE.
|
||||||
|
atTheadCls = "sticky top-0 z-10 [&_th]:border-b [&_th]:border-neutral-300"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AutoTableColumn describes one column: its header text, alignment, an optional
|
||||||
|
// static Tailwind width class (the static replacement for the TSX's measured/
|
||||||
|
// resizable widths), whether it is sortable (and under which identifier), and a
|
||||||
|
// cell render func returning the full <td> for a given row. If Cell is nil an
|
||||||
|
// empty aligned <td> is rendered.
|
||||||
|
//
|
||||||
|
// NOTE: The TSX AutoTableColumn also carries csv/csvValue, sortType/sortValue,
|
||||||
|
// toggleable/hiddenByDefault, and a `calculated` spec — all tied to features that
|
||||||
|
// are out of scope here (export, local sort, toggles, calculated columns).
|
||||||
|
type AutoTableColumn struct {
|
||||||
|
DisplayName string
|
||||||
|
DisplayPosition ColumnPosition
|
||||||
|
WidthClass string // static Tailwind width, e.g. "w-32" (replaces measured sizing)
|
||||||
|
HeaderClasses string
|
||||||
|
Sortable bool
|
||||||
|
SortIdentifier string
|
||||||
|
Cell func(row any) *vdom.VNode
|
||||||
|
}
|
||||||
|
|
||||||
|
// AutoTableOrderBy is the active sort (mirrors the TSX interface).
|
||||||
|
type AutoTableOrderBy struct {
|
||||||
|
Identifier string
|
||||||
|
Descending bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// AutoTablePagination is the display-only pagination state (mirrors the TSX
|
||||||
|
// interface; only the display fields are used by this core).
|
||||||
|
type AutoTablePagination struct {
|
||||||
|
CurrentPage int
|
||||||
|
TotalPages int
|
||||||
|
TotalItems int
|
||||||
|
MaxItemsPerPage int
|
||||||
|
ViewRangeLower int
|
||||||
|
ViewRangeUpper int
|
||||||
|
}
|
||||||
|
|
||||||
|
// atConfig holds resolved AutoTable options. Defaults mirror the TSX opts memo.
|
||||||
|
type atConfig struct {
|
||||||
|
size AutoTableSize
|
||||||
|
color AutoTableHeaderColor
|
||||||
|
shadow bool
|
||||||
|
hover bool
|
||||||
|
alternate bool
|
||||||
|
headerBorderY bool
|
||||||
|
surroundingBorder bool
|
||||||
|
borderX bool
|
||||||
|
borderY bool
|
||||||
|
tableLayoutAuto bool
|
||||||
|
hidePagination bool
|
||||||
|
|
||||||
|
loading bool
|
||||||
|
errorMsg string
|
||||||
|
emptyMessage string
|
||||||
|
|
||||||
|
sortIdentifier string
|
||||||
|
sortDescending bool
|
||||||
|
onSort func(identifier string)
|
||||||
|
|
||||||
|
pagination *AutoTablePagination
|
||||||
|
onPageChange func(page int)
|
||||||
|
onItemsPerPage func(n int)
|
||||||
|
|
||||||
|
class string
|
||||||
|
}
|
||||||
|
|
||||||
|
// AutoTableOption configures AutoTable (functional-options for the variadic opts).
|
||||||
|
type AutoTableOption func(*atConfig)
|
||||||
|
|
||||||
|
// AutoTableWithSize sets the density (default / compact / supercompact).
|
||||||
|
func AutoTableWithSize(s AutoTableSize) AutoTableOption {
|
||||||
|
return func(c *atConfig) { c.size = s }
|
||||||
|
}
|
||||||
|
|
||||||
|
// AutoTableWithColor sets the header/body color scheme.
|
||||||
|
func AutoTableWithColor(color AutoTableHeaderColor) AutoTableOption {
|
||||||
|
return func(c *atConfig) { c.color = color }
|
||||||
|
}
|
||||||
|
|
||||||
|
// AutoTableWithHover enables per-row hover highlighting.
|
||||||
|
func AutoTableWithHover() AutoTableOption { return func(c *atConfig) { c.hover = true } }
|
||||||
|
|
||||||
|
// AutoTableWithAlternate enables zebra striping on odd rows.
|
||||||
|
func AutoTableWithAlternate() AutoTableOption { return func(c *atConfig) { c.alternate = true } }
|
||||||
|
|
||||||
|
// AutoTableWithShadow adds a drop shadow to the table container.
|
||||||
|
func AutoTableWithShadow() AutoTableOption { return func(c *atConfig) { c.shadow = true } }
|
||||||
|
|
||||||
|
// AutoTableWithSurroundingBorder draws a border around the table container.
|
||||||
|
func AutoTableWithSurroundingBorder() AutoTableOption {
|
||||||
|
return func(c *atConfig) { c.surroundingBorder = true }
|
||||||
|
}
|
||||||
|
|
||||||
|
// AutoTableWithHeaderBorderY adds vertical dividers between header cells.
|
||||||
|
func AutoTableWithHeaderBorderY() AutoTableOption {
|
||||||
|
return func(c *atConfig) { c.headerBorderY = true }
|
||||||
|
}
|
||||||
|
|
||||||
|
// AutoTableWithBorderX draws horizontal dividers between body rows.
|
||||||
|
func AutoTableWithBorderX() AutoTableOption { return func(c *atConfig) { c.borderX = true } }
|
||||||
|
|
||||||
|
// AutoTableWithBorderY draws vertical dividers between body cells.
|
||||||
|
func AutoTableWithBorderY() AutoTableOption { return func(c *atConfig) { c.borderY = true } }
|
||||||
|
|
||||||
|
// AutoTableWithTableLayoutAuto uses auto table layout instead of table-fixed.
|
||||||
|
func AutoTableWithTableLayoutAuto() AutoTableOption {
|
||||||
|
return func(c *atConfig) { c.tableLayoutAuto = true }
|
||||||
|
}
|
||||||
|
|
||||||
|
// AutoTableWithLoading renders skeleton placeholder rows instead of data.
|
||||||
|
func AutoTableWithLoading(loading bool) AutoTableOption {
|
||||||
|
return func(c *atConfig) { c.loading = loading }
|
||||||
|
}
|
||||||
|
|
||||||
|
// AutoTableWithError renders a single error row with the given message.
|
||||||
|
func AutoTableWithError(msg string) AutoTableOption {
|
||||||
|
return func(c *atConfig) { c.errorMsg = msg }
|
||||||
|
}
|
||||||
|
|
||||||
|
// AutoTableWithEmptyMessage overrides the "No entries found." empty-state text.
|
||||||
|
func AutoTableWithEmptyMessage(msg string) AutoTableOption {
|
||||||
|
return func(c *atConfig) { c.emptyMessage = msg }
|
||||||
|
}
|
||||||
|
|
||||||
|
// AutoTableWithSort surfaces the active sort as a plain value + callback. onSort
|
||||||
|
// is invoked with a sortable column's SortIdentifier when its header is clicked;
|
||||||
|
// the caller owns the actual re-sorting (the TSX's local sort is out of scope).
|
||||||
|
func AutoTableWithSort(identifier string, descending bool, onSort func(identifier string)) AutoTableOption {
|
||||||
|
return func(c *atConfig) {
|
||||||
|
c.sortIdentifier = identifier
|
||||||
|
c.sortDescending = descending
|
||||||
|
c.onSort = onSort
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AutoTableWithPagination surfaces display-only pagination state + callbacks. The
|
||||||
|
// caller owns the actual paging/query computation.
|
||||||
|
func AutoTableWithPagination(p *AutoTablePagination, onPageChange func(page int), onItemsPerPage func(n int)) AutoTableOption {
|
||||||
|
return func(c *atConfig) {
|
||||||
|
c.pagination = p
|
||||||
|
c.onPageChange = onPageChange
|
||||||
|
c.onItemsPerPage = onItemsPerPage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AutoTableWithHidePagination hides the pagination bar.
|
||||||
|
func AutoTableWithHidePagination() AutoTableOption {
|
||||||
|
return func(c *atConfig) { c.hidePagination = true }
|
||||||
|
}
|
||||||
|
|
||||||
|
// AutoTableWithClass appends classes to the outermost wrapper.
|
||||||
|
func AutoTableWithClass(class string) AutoTableOption {
|
||||||
|
return func(c *atConfig) { c.class = class }
|
||||||
|
}
|
||||||
|
|
||||||
|
// AutoTable renders the table shell for the given columns and rows. Sort state,
|
||||||
|
// loading, and pagination are plain value props supplied via opts (reactive
|
||||||
|
// accessors collapse). See the file-level NOTE for out-of-scope features.
|
||||||
|
func AutoTable(cols []AutoTableColumn, rows []any, opts ...AutoTableOption) *vdom.VNode {
|
||||||
|
cfg := &atConfig{}
|
||||||
|
for _, o := range opts {
|
||||||
|
o(cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
tableCls := cx("border-collapse", TBL_BASE)
|
||||||
|
if !cfg.tableLayoutAuto {
|
||||||
|
tableCls = cx(tableCls, "table-fixed")
|
||||||
|
}
|
||||||
|
|
||||||
|
containerCls := TBL_CONTAINER
|
||||||
|
if cfg.surroundingBorder {
|
||||||
|
containerCls = cx(containerCls, "border border-neutral-300")
|
||||||
|
}
|
||||||
|
if cfg.shadow {
|
||||||
|
containerCls = cx(containerCls, "shadow-sm")
|
||||||
|
}
|
||||||
|
|
||||||
|
table := vdom.El("table",
|
||||||
|
vdom.Attr("class", tableCls),
|
||||||
|
atRenderHead(cols, cfg),
|
||||||
|
atRenderBody(cols, rows, cfg),
|
||||||
|
)
|
||||||
|
|
||||||
|
container := vdom.El("div", vdom.Attr("class", containerCls),
|
||||||
|
vdom.El("div", vdom.Attr("class", TBL_WRAPPER), table),
|
||||||
|
)
|
||||||
|
if footer := atRenderPagination(cfg); footer != nil {
|
||||||
|
container.Children = append(container.Children, footer)
|
||||||
|
}
|
||||||
|
|
||||||
|
return vdom.El("div",
|
||||||
|
vdom.Attr("class", cx("min-w-0 w-full max-w-full", cfg.class)),
|
||||||
|
container,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// atRenderHead builds the <thead> with one header <tr>.
|
||||||
|
func atRenderHead(cols []AutoTableColumn, cfg *atConfig) *vdom.VNode {
|
||||||
|
headerColor := HEADER_COLOR_CLS[cfg.color]
|
||||||
|
headerPadding := HEADER_PADDING_CLS[cfg.size]
|
||||||
|
|
||||||
|
tr := vdom.El("tr")
|
||||||
|
if len(cols) == 0 {
|
||||||
|
tr.Children = append(tr.Children,
|
||||||
|
vdom.El("th", vdom.Attr("class", cx(headerPadding, headerColor)), vdom.Text(" ")))
|
||||||
|
}
|
||||||
|
for i, col := range cols {
|
||||||
|
tr.Children = append(tr.Children, atRenderHeaderCell(col, i, cfg, headerColor, headerPadding))
|
||||||
|
}
|
||||||
|
return vdom.El("thead", vdom.Attr("class", atTheadCls), tr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// atRenderHeaderCell builds one <th>. Drag/resize handles are out of scope.
|
||||||
|
func atRenderHeaderCell(col AutoTableColumn, displayIdx int, cfg *atConfig, headerColor, headerPadding string) *vdom.VNode {
|
||||||
|
pos := col.DisplayPosition
|
||||||
|
posCls := POS_CLS[pos]
|
||||||
|
|
||||||
|
thCls := cx(headerPadding, headerColor, col.WidthClass, posCls)
|
||||||
|
if cfg.headerBorderY && displayIdx > 0 {
|
||||||
|
thCls = cx(thCls, "border-l border-l-neutral-300")
|
||||||
|
}
|
||||||
|
if col.Sortable {
|
||||||
|
thCls = cx(thCls, "cursor-pointer", atHeaderSortHoverCls[cfg.color])
|
||||||
|
}
|
||||||
|
thCls = cx(thCls, col.HeaderClasses)
|
||||||
|
|
||||||
|
mods := []vdom.Mod{vdom.Attr("class", thCls)}
|
||||||
|
if col.Sortable {
|
||||||
|
sortID := col.SortIdentifier
|
||||||
|
if sortID != "" && cfg.onSort != nil {
|
||||||
|
mods = append(mods, vdom.On(vdom.EVENT_CLICK, func() { cfg.onSort(sortID) }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inner content: label (grows) + sort caret.
|
||||||
|
inner := vdom.El("div", vdom.Attr("class", cx(HEADER_INNER_BASE, HEADER_INNER_POS[pos])),
|
||||||
|
vdom.El("div", vdom.Attr("class", cx("grow text-sm", HEADER_TEXT_CLS[cfg.color])), vdom.Text(col.DisplayName)),
|
||||||
|
)
|
||||||
|
if col.Sortable {
|
||||||
|
iconWrap := vdom.El("div", vdom.Attr("class", cx(atSortIconWrap, "w-4 text-center", atHeaderSortIconCls[cfg.color])))
|
||||||
|
if col.SortIdentifier != "" && cfg.sortIdentifier == col.SortIdentifier {
|
||||||
|
// caret-up/caret-down are not in the default icon registry, so they
|
||||||
|
// render as empty boxes until an app registers them (see Icons.go).
|
||||||
|
if cfg.sortDescending {
|
||||||
|
iconWrap.Children = append(iconWrap.Children, Icon("caret-down", 16, ""))
|
||||||
|
} else {
|
||||||
|
iconWrap.Children = append(iconWrap.Children, Icon("caret-up", 16, ""))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
inner.Children = append(inner.Children, iconWrap)
|
||||||
|
}
|
||||||
|
|
||||||
|
content := vdom.El("div", vdom.Attr("class", HEADER_CONTENT), inner)
|
||||||
|
mods = append(mods, content)
|
||||||
|
return vdom.El("th", mods...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// atRenderBody builds the <tbody> with its loading / error / empty / data states.
|
||||||
|
func atRenderBody(cols []AutoTableColumn, rows []any, cfg *atConfig) *vdom.VNode {
|
||||||
|
bodyCls := BODY_PADDING_CLS[cfg.size]
|
||||||
|
if cfg.borderY {
|
||||||
|
bodyCls = cx(bodyCls, "[&_td+td]:border-l [&_td+td]:border-neutral-300")
|
||||||
|
}
|
||||||
|
|
||||||
|
colspan := len(cols)
|
||||||
|
if colspan == 0 {
|
||||||
|
colspan = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody := vdom.El("tbody", vdom.Attr("class", bodyCls))
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case cfg.loading:
|
||||||
|
// NOTE: the TSX randomizes each skeleton's width; a fixed width is used
|
||||||
|
// here (no measurement/randomness in the neutral runtime).
|
||||||
|
for r := 0; r < 5; r++ {
|
||||||
|
tr := vdom.El("tr")
|
||||||
|
if cfg.alternate && r%2 == 1 {
|
||||||
|
tr.Attrs["class"] = "bg-neutral-100"
|
||||||
|
}
|
||||||
|
for range cols {
|
||||||
|
tr.Children = append(tr.Children, vdom.El("td",
|
||||||
|
vdom.El("div", vdom.Attr("class", atSkeleton), vdom.Attr("style", "width: 70%")),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
tbody.Children = append(tbody.Children, tr)
|
||||||
|
}
|
||||||
|
case cfg.errorMsg != "":
|
||||||
|
tbody.Children = append(tbody.Children, vdom.El("tr",
|
||||||
|
vdom.El("td", vdom.Attr("colspan", strconv.Itoa(colspan)), vdom.Attr("class", atErrorCell),
|
||||||
|
vdom.Text("Error: "+cfg.errorMsg)),
|
||||||
|
))
|
||||||
|
case len(cols) == 0:
|
||||||
|
tbody.Children = append(tbody.Children, vdom.El("tr",
|
||||||
|
vdom.El("td", vdom.Attr("colspan", strconv.Itoa(colspan)), vdom.Attr("class", atEmptyCell),
|
||||||
|
vdom.Text("No columns selected.")),
|
||||||
|
))
|
||||||
|
case len(rows) == 0:
|
||||||
|
tbody.Children = append(tbody.Children, vdom.El("tr",
|
||||||
|
vdom.El("td", vdom.Attr("colspan", strconv.Itoa(colspan)),
|
||||||
|
vdom.Text(pick(cfg.emptyMessage, "No entries found."))),
|
||||||
|
))
|
||||||
|
default:
|
||||||
|
hoverCls := ""
|
||||||
|
if cfg.hover {
|
||||||
|
hoverCls = atRowHoverCls[cfg.color]
|
||||||
|
}
|
||||||
|
for rowIdx, row := range rows {
|
||||||
|
isLast := rowIdx == len(rows)-1
|
||||||
|
rowCls := ""
|
||||||
|
if cfg.alternate && rowIdx%2 == 1 {
|
||||||
|
rowCls = cx(rowCls, "bg-neutral-100")
|
||||||
|
}
|
||||||
|
rowCls = cx(rowCls, hoverCls)
|
||||||
|
if cfg.borderX && !isLast {
|
||||||
|
rowCls = cx(rowCls, "border-b border-neutral-300")
|
||||||
|
}
|
||||||
|
|
||||||
|
tr := vdom.El("tr")
|
||||||
|
if rowCls != "" {
|
||||||
|
tr.Attrs["class"] = rowCls
|
||||||
|
}
|
||||||
|
for _, col := range cols {
|
||||||
|
tr.Children = append(tr.Children, atRenderCell(col, row))
|
||||||
|
}
|
||||||
|
tbody.Children = append(tbody.Children, tr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tbody
|
||||||
|
}
|
||||||
|
|
||||||
|
// atRenderCell renders a column's cell for a row. The cell func returns the full
|
||||||
|
// <td>; if nil, an empty aligned <td> is produced.
|
||||||
|
func atRenderCell(col AutoTableColumn, row any) *vdom.VNode {
|
||||||
|
if col.Cell != nil {
|
||||||
|
if td := col.Cell(row); td != nil {
|
||||||
|
return td
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return vdom.El("td", vdom.Attr("class", POS_CLS[col.DisplayPosition]))
|
||||||
|
}
|
||||||
|
|
||||||
|
// atRenderPagination builds the display-only pagination bar, or nil when there is
|
||||||
|
// nothing to show. Page/items-per-page changes are surfaced via callbacks.
|
||||||
|
func atRenderPagination(cfg *atConfig) *vdom.VNode {
|
||||||
|
if cfg.pagination == nil || cfg.hidePagination {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
p := cfg.pagination
|
||||||
|
|
||||||
|
info := vdom.El("div", vdom.Attr("class", atPaginationInfo),
|
||||||
|
vdom.El("b", vdom.Attr("class", "leading-none"), Icon("list-ol", 16, "")),
|
||||||
|
vdom.El("span", vdom.Attr("class", "ml-3"),
|
||||||
|
vdom.Text(strconv.Itoa(p.ViewRangeLower)+"-"+strconv.Itoa(p.ViewRangeUpper)+" of "+strconv.Itoa(p.TotalItems))),
|
||||||
|
)
|
||||||
|
|
||||||
|
// NOTE: the TSX uses the ported FormSelect; a bare <select> is used here.
|
||||||
|
sel := vdom.El("select", vdom.Attr("class", "mr-5"))
|
||||||
|
for _, n := range []int{5, 10, 25, 50, 100} {
|
||||||
|
optMods := []vdom.Mod{vdom.Attr("value", strconv.Itoa(n)), vdom.Text(strconv.Itoa(n))}
|
||||||
|
if n == p.MaxItemsPerPage {
|
||||||
|
optMods = append(optMods, vdom.Attr("selected", "selected"))
|
||||||
|
}
|
||||||
|
sel.Children = append(sel.Children, vdom.El("option", optMods...))
|
||||||
|
}
|
||||||
|
if cfg.onItemsPerPage != nil {
|
||||||
|
sel.Events[vdom.EVENT_CHANGE] = func(e vdom.Event) {
|
||||||
|
if n, err := strconv.Atoi(e.Value()); err == nil {
|
||||||
|
cfg.onItemsPerPage(n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
page := func(to int) func() {
|
||||||
|
return func() {
|
||||||
|
if cfg.onPageChange != nil {
|
||||||
|
cfg.onPageChange(to)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
controls := vdom.El("div", vdom.Attr("class", atPaginationControls),
|
||||||
|
vdom.El("div", vdom.Attr("class", atPaginationLabel), vdom.Text("Items per page:")),
|
||||||
|
sel,
|
||||||
|
atPaginationButton(page(1), p.CurrentPage <= 1, Icon("angles-left", 16, "")),
|
||||||
|
atPaginationButton(page(p.CurrentPage-1), p.CurrentPage <= 1, Icon("chevron-left", 16, "")),
|
||||||
|
vdom.El("div", vdom.Attr("class", atPaginationPage),
|
||||||
|
vdom.Text("Page "+strconv.Itoa(p.CurrentPage)+" of "+strconv.Itoa(p.TotalPages))),
|
||||||
|
atPaginationButton(page(p.CurrentPage+1), p.CurrentPage >= p.TotalPages, Icon("chevron-right", 16, "")),
|
||||||
|
atPaginationButton(page(p.TotalPages), p.CurrentPage >= p.TotalPages, Icon("angles-right", 16, "")),
|
||||||
|
)
|
||||||
|
|
||||||
|
return vdom.El("div", vdom.Attr("class", cx(atPaginationBase, atPaginationPaddingCls[cfg.size])),
|
||||||
|
vdom.El("div", vdom.Attr("class", "flex items-center"), info),
|
||||||
|
controls,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// atPaginationButton is one pagination control button (mirrors TSX PaginationButton).
|
||||||
|
func atPaginationButton(onClick func(), disabled bool, child *vdom.VNode) *vdom.VNode {
|
||||||
|
mods := []vdom.Mod{vdom.Attr("class", atPaginationBtn)}
|
||||||
|
if disabled {
|
||||||
|
mods = append(mods, vdom.Attr("disabled", "disabled"))
|
||||||
|
} else if onClick != nil {
|
||||||
|
mods = append(mods, vdom.On(vdom.EVENT_CLICK, onClick))
|
||||||
|
}
|
||||||
|
if child != nil {
|
||||||
|
mods = append(mods, child)
|
||||||
|
}
|
||||||
|
return vdom.El("button", mods...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AutoTableTdLeft / AutoTableTdRight / AutoTableTdCenter build an aligned <td>,
|
||||||
|
// convenient for AutoTableColumn.Cell funcs (mirror the TSX TdLeft/Right/Center).
|
||||||
|
func AutoTableTdLeft(class string, children ...*vdom.VNode) *vdom.VNode {
|
||||||
|
return vdom.El("td", kids([]vdom.Mod{vdom.Attr("class", cx("text-left", class))}, children)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func AutoTableTdRight(class string, children ...*vdom.VNode) *vdom.VNode {
|
||||||
|
return vdom.El("td", kids([]vdom.Mod{vdom.Attr("class", cx("text-right", class))}, children)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func AutoTableTdCenter(class string, children ...*vdom.VNode) *vdom.VNode {
|
||||||
|
return vdom.El("td", kids([]vdom.Mod{vdom.Attr("class", cx("text-center", class))}, children)...)
|
||||||
|
}
|
||||||
71
go/webui/badges.go
Normal file
71
go/webui/badges.go
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
package webui
|
||||||
|
|
||||||
|
import "kjol/vdom"
|
||||||
|
|
||||||
|
// Port of web/kit/Badges.tsx.
|
||||||
|
|
||||||
|
const (
|
||||||
|
BadgeGreen = "green"
|
||||||
|
BadgeRed = "red"
|
||||||
|
BadgeBlue = "blue"
|
||||||
|
BadgeAmber = "amber"
|
||||||
|
BadgeNeutral = "neutral"
|
||||||
|
BadgeMuted = "muted"
|
||||||
|
)
|
||||||
|
|
||||||
|
const badgeBase = "inline-flex items-center gap-1 text-xs font-semibold py-0.5 px-2 rounded-default whitespace-nowrap"
|
||||||
|
|
||||||
|
var badgeColors = map[string]string{
|
||||||
|
"green": "text-white bg-green-700",
|
||||||
|
"red": "text-white bg-red-700",
|
||||||
|
"blue": "text-white bg-sky-800",
|
||||||
|
"amber": "text-white bg-amber-700",
|
||||||
|
"neutral": "text-white bg-neutral-500",
|
||||||
|
"muted": "text-neutral-400 bg-transparent",
|
||||||
|
}
|
||||||
|
|
||||||
|
// BadgeProps configures Badge. When OnClick is set the badge renders as a
|
||||||
|
// <button> (same visuals, interactive).
|
||||||
|
type BadgeProps struct {
|
||||||
|
Color string
|
||||||
|
Pill bool
|
||||||
|
OnClick func()
|
||||||
|
Disabled bool
|
||||||
|
Title string
|
||||||
|
Class string
|
||||||
|
}
|
||||||
|
|
||||||
|
func badgeClass(p BadgeProps) string {
|
||||||
|
c := badgeBase
|
||||||
|
if p.Pill {
|
||||||
|
c = cx(c, "rounded-full")
|
||||||
|
}
|
||||||
|
cc := badgeColors[p.Color]
|
||||||
|
if cc == "" {
|
||||||
|
cc = badgeColors["neutral"]
|
||||||
|
}
|
||||||
|
c = cx(c, cc)
|
||||||
|
if p.OnClick != nil {
|
||||||
|
c = cx(c, "cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed border-0")
|
||||||
|
}
|
||||||
|
return cx(c, p.Class)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Badge renders a small status pill (or interactive button when OnClick is set).
|
||||||
|
func Badge(p BadgeProps, children ...*vdom.VNode) *vdom.VNode {
|
||||||
|
if p.OnClick != nil {
|
||||||
|
mods := []vdom.Mod{vdom.Attr("type", "button"), vdom.Attr("class", badgeClass(p)), vdom.On(vdom.EVENT_CLICK, p.OnClick)}
|
||||||
|
if p.Disabled {
|
||||||
|
mods = append(mods, vdom.Attr("disabled", "disabled"))
|
||||||
|
}
|
||||||
|
if p.Title != "" {
|
||||||
|
mods = append(mods, vdom.Attr("title", p.Title))
|
||||||
|
}
|
||||||
|
return vdom.El("button", kids(mods, children)...)
|
||||||
|
}
|
||||||
|
mods := []vdom.Mod{vdom.Attr("class", badgeClass(p))}
|
||||||
|
if p.Title != "" {
|
||||||
|
mods = append(mods, vdom.Attr("title", p.Title))
|
||||||
|
}
|
||||||
|
return vdom.El("span", kids(mods, children)...)
|
||||||
|
}
|
||||||
214
go/webui/buttons.go
Normal file
214
go/webui/buttons.go
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
package webui
|
||||||
|
|
||||||
|
import "kjol/vdom"
|
||||||
|
|
||||||
|
// Port of web/kit/Buttons.tsx.
|
||||||
|
|
||||||
|
const (
|
||||||
|
ButtonNeutral = "neutral"
|
||||||
|
ButtonWhite = "white"
|
||||||
|
ButtonLightNeutral = "light-neutral"
|
||||||
|
ButtonBlue = "blue"
|
||||||
|
ButtonDarkBlue = "dark-blue"
|
||||||
|
ButtonGreen = "green"
|
||||||
|
ButtonDarkGreen = "dark-green"
|
||||||
|
ButtonRed = "red"
|
||||||
|
ButtonDarkRed = "dark-red"
|
||||||
|
ButtonYellow = "yellow"
|
||||||
|
ButtonOrange = "orange"
|
||||||
|
ButtonPrimary = "primary"
|
||||||
|
ButtonSecondary = "secondary"
|
||||||
|
ButtonGhost = "ghost"
|
||||||
|
)
|
||||||
|
|
||||||
|
const btnBase = "inline-flex items-center justify-center gap-2 cursor-pointer text-sm font-normal rounded-default transition disabled:opacity-50 disabled:cursor-not-allowed focus-visible:outline-2 focus-visible:outline-current focus-visible:outline-offset-2"
|
||||||
|
|
||||||
|
var btnColors = map[string]string{
|
||||||
|
"neutral": "shadow-xs bg-neutral-700 text-white hover:bg-neutral-800",
|
||||||
|
"white": "shadow-xs bg-white text-black border border-neutral-300 hover:bg-neutral-50",
|
||||||
|
"light-neutral": "shadow-xs bg-neutral-50 text-black border border-neutral-300 hover:bg-neutral-100",
|
||||||
|
"blue": "shadow-xs bg-sky-700 text-white hover:bg-sky-800",
|
||||||
|
"dark-blue": "shadow-xs bg-sky-900 text-white hover:bg-sky-950",
|
||||||
|
"green": "shadow-xs bg-green-700 text-white hover:bg-green-800",
|
||||||
|
"dark-green": "shadow-xs bg-green-900 text-white hover:bg-green-950",
|
||||||
|
"red": "shadow-xs bg-red-700 text-white hover:bg-red-800",
|
||||||
|
"dark-red": "shadow-xs bg-red-900 text-white hover:bg-red-950",
|
||||||
|
"yellow": "shadow-xs bg-yellow-700 text-white hover:bg-yellow-800",
|
||||||
|
"orange": "shadow-xs bg-orange-600 text-white hover:bg-orange-700",
|
||||||
|
"primary": "shadow-xs bg-primary text-white hover:bg-primary-hover",
|
||||||
|
"secondary": "shadow-none bg-neutral-100 text-neutral-700 border border-neutral-300 hover:bg-neutral-200",
|
||||||
|
"ghost": "shadow-none bg-transparent text-neutral-600 border-none hover:bg-neutral-100",
|
||||||
|
}
|
||||||
|
|
||||||
|
var btnOutlineColors = map[string]string{
|
||||||
|
"neutral": "text-neutral-700",
|
||||||
|
"white": "text-neutral-300",
|
||||||
|
"light-neutral": "text-neutral-300",
|
||||||
|
"blue": "text-sky-700",
|
||||||
|
"dark-blue": "text-sky-900",
|
||||||
|
"green": "text-green-700",
|
||||||
|
"dark-green": "text-green-900",
|
||||||
|
"red": "text-red-700",
|
||||||
|
"dark-red": "text-red-900",
|
||||||
|
"yellow": "text-yellow-700",
|
||||||
|
"orange": "text-orange-600",
|
||||||
|
"primary": "text-primary",
|
||||||
|
}
|
||||||
|
|
||||||
|
const btnOutlineBase = "bg-transparent shadow-[inset_0_0_0_1px_currentColor] hover:shadow-[inset_0_0_0_2px_currentColor]"
|
||||||
|
|
||||||
|
// ButtonProps configures Button. Icon is an icon name (see webui.Icon); Text is
|
||||||
|
// the label. If neither Text nor children are given the button is icon-only.
|
||||||
|
type ButtonProps struct {
|
||||||
|
Color string
|
||||||
|
Outline bool
|
||||||
|
Small bool
|
||||||
|
Icon string
|
||||||
|
Text string
|
||||||
|
Type string
|
||||||
|
Disabled bool
|
||||||
|
Title string
|
||||||
|
Class string
|
||||||
|
OnClick func()
|
||||||
|
}
|
||||||
|
|
||||||
|
func buttonClass(p ButtonProps) string {
|
||||||
|
c := btnBase
|
||||||
|
if p.Outline {
|
||||||
|
oc := btnOutlineColors[p.Color]
|
||||||
|
if oc == "" {
|
||||||
|
oc = btnOutlineColors["neutral"]
|
||||||
|
}
|
||||||
|
c = cx(c, btnOutlineBase, oc)
|
||||||
|
} else {
|
||||||
|
cc := btnColors[p.Color]
|
||||||
|
if cc == "" {
|
||||||
|
cc = btnColors["neutral"]
|
||||||
|
}
|
||||||
|
c = cx(c, cc)
|
||||||
|
}
|
||||||
|
hasText := p.Text != ""
|
||||||
|
switch {
|
||||||
|
case p.Small && p.Icon != "":
|
||||||
|
c = cx(c, "py-1 px-3")
|
||||||
|
case p.Small:
|
||||||
|
c = cx(c, "py-1 px-4")
|
||||||
|
case p.Icon != "" && !hasText:
|
||||||
|
c = cx(c, "py-2 px-3")
|
||||||
|
case p.Icon != "":
|
||||||
|
c = cx(c, "py-2 px-5")
|
||||||
|
default:
|
||||||
|
c = cx(c, "py-2 px-8")
|
||||||
|
}
|
||||||
|
return cx(c, p.Class)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Button renders a styled <button>. Extra children render after any Icon/Text.
|
||||||
|
func Button(p ButtonProps, children ...*vdom.VNode) *vdom.VNode {
|
||||||
|
mods := []vdom.Mod{
|
||||||
|
vdom.Attr("type", pick(p.Type, "button")),
|
||||||
|
vdom.Attr("class", buttonClass(p)),
|
||||||
|
}
|
||||||
|
if p.Disabled {
|
||||||
|
mods = append(mods, vdom.Attr("disabled", "disabled"))
|
||||||
|
}
|
||||||
|
if p.Title != "" {
|
||||||
|
mods = append(mods, vdom.Attr("title", p.Title))
|
||||||
|
}
|
||||||
|
if p.OnClick != nil {
|
||||||
|
mods = append(mods, vdom.On(vdom.EVENT_CLICK, p.OnClick))
|
||||||
|
}
|
||||||
|
if p.Icon != "" {
|
||||||
|
mods = append(mods, Icon(p.Icon, iconSize(p.Small), ""))
|
||||||
|
}
|
||||||
|
if p.Text != "" {
|
||||||
|
mods = append(mods, vdom.Text(p.Text))
|
||||||
|
}
|
||||||
|
mods = kids(mods, children)
|
||||||
|
return vdom.El("button", mods...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func iconSize(small bool) int {
|
||||||
|
if small {
|
||||||
|
return 14
|
||||||
|
}
|
||||||
|
return 16
|
||||||
|
}
|
||||||
|
|
||||||
|
// ButtonLink is a text-styled link button (blue).
|
||||||
|
func ButtonLink(onClick func(), children ...*vdom.VNode) *vdom.VNode {
|
||||||
|
mods := []vdom.Mod{
|
||||||
|
vdom.Attr("type", "button"),
|
||||||
|
vdom.Attr("class", "cursor-pointer bg-transparent border-none p-0 font-[inherit] text-sky-700 hover:underline"),
|
||||||
|
}
|
||||||
|
if onClick != nil {
|
||||||
|
mods = append(mods, vdom.On(vdom.EVENT_CLICK, onClick))
|
||||||
|
}
|
||||||
|
return vdom.El("button", kids(mods, children)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ButtonLinkRed is the red variant of ButtonLink.
|
||||||
|
func ButtonLinkRed(onClick func(), children ...*vdom.VNode) *vdom.VNode {
|
||||||
|
mods := []vdom.Mod{
|
||||||
|
vdom.Attr("type", "button"),
|
||||||
|
vdom.Attr("class", "cursor-pointer bg-transparent border-none p-0 font-[inherit] text-red-600 hover:underline"),
|
||||||
|
}
|
||||||
|
if onClick != nil {
|
||||||
|
mods = append(mods, vdom.On(vdom.EVENT_CLICK, onClick))
|
||||||
|
}
|
||||||
|
return vdom.El("button", kids(mods, children)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SegmentedButtonOption is one option in a SegmentedButtons group.
|
||||||
|
type SegmentedButtonOption struct {
|
||||||
|
Value string
|
||||||
|
Label string
|
||||||
|
Icon string
|
||||||
|
}
|
||||||
|
|
||||||
|
// SegmentedButtons renders a pill group of mutually-exclusive options; `value`
|
||||||
|
// is the selected option's value, `onChange` receives the clicked value.
|
||||||
|
func SegmentedButtons(options []SegmentedButtonOption, value string, onChange func(string), small bool, class string) *vdom.VNode {
|
||||||
|
sizeCls := "py-1 px-3 text-sm"
|
||||||
|
if small {
|
||||||
|
sizeCls = "py-0.5 px-2 text-xs"
|
||||||
|
}
|
||||||
|
const baseCls = "inline-flex items-center justify-center gap-1.5 flex-1 cursor-pointer font-medium transition-colors whitespace-nowrap disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
const activeCls = "bg-white text-text-heading shadow-sm"
|
||||||
|
const inactiveCls = "text-neutral-500 hover:text-neutral-700"
|
||||||
|
|
||||||
|
mods := []vdom.Mod{vdom.Attr("class", cx("flex items-center gap-0.5 rounded-md bg-neutral-100 p-0.5", class))}
|
||||||
|
for _, opt := range options {
|
||||||
|
state := inactiveCls
|
||||||
|
if opt.Value == value {
|
||||||
|
state = activeCls
|
||||||
|
}
|
||||||
|
v := opt.Value
|
||||||
|
btnMods := []vdom.Mod{
|
||||||
|
vdom.Attr("type", "button"),
|
||||||
|
vdom.Attr("class", cx(baseCls, "rounded-default", sizeCls, state)),
|
||||||
|
vdom.Attr("title", opt.Label),
|
||||||
|
vdom.On(vdom.EVENT_CLICK, func() { onChange(v) }),
|
||||||
|
}
|
||||||
|
if opt.Icon != "" {
|
||||||
|
btnMods = append(btnMods, Icon(opt.Icon, 12, ""))
|
||||||
|
}
|
||||||
|
btnMods = append(btnMods, vdom.El("span", vdom.Text(opt.Label)))
|
||||||
|
mods = append(mods, vdom.El("button", btnMods...))
|
||||||
|
}
|
||||||
|
return vdom.El("div", mods...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BackLink is an inline chevron-left anchor. onDark switches to on-dark colors.
|
||||||
|
func BackLink(href, text string, onDark bool) *vdom.VNode {
|
||||||
|
color := "text-neutral-600 hover:text-neutral-900"
|
||||||
|
if onDark {
|
||||||
|
color = "text-text-on-dark-muted hover:text-text-on-dark"
|
||||||
|
}
|
||||||
|
return vdom.El("a",
|
||||||
|
vdom.Attr("href", href),
|
||||||
|
vdom.Attr("class", cx("inline-flex items-center gap-1 text-sm no-underline", color)),
|
||||||
|
Icon("chevron-left", 16, ""),
|
||||||
|
vdom.Text(text),
|
||||||
|
)
|
||||||
|
}
|
||||||
322
go/webui/calendar.go
Normal file
322
go/webui/calendar.go
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
// Port of web/kit/Calendar.tsx. Date math uses the stdlib time package.
|
||||||
|
package webui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"kjol/vdom"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Day-of-week and month labels (module-level in the TSX). Prefixed to stay
|
||||||
|
// private to the Calendar/DatePicker files that share them.
|
||||||
|
var calDays = []string{"Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"}
|
||||||
|
|
||||||
|
var calMonths = []string{
|
||||||
|
"January", "February", "March", "April", "May", "June",
|
||||||
|
"July", "August", "September", "October", "November", "December",
|
||||||
|
}
|
||||||
|
|
||||||
|
var calMonthsShort = []string{
|
||||||
|
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
||||||
|
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Shared Tailwind class constants (also used by datepicker.go). These are
|
||||||
|
// exported from Calendar.tsx; here they stay unexported with a cal prefix so the
|
||||||
|
// two ported files can share them without widening the package's public API. --
|
||||||
|
const calPickerRoot = "p-2 min-w-[240px]"
|
||||||
|
const calMonthRoot = "p-0 min-w-0 w-full bg-white border border-neutral-200 rounded-default shadow-sm overflow-hidden"
|
||||||
|
|
||||||
|
const calHeaderPicker = "flex items-center justify-between mb-2 gap-1"
|
||||||
|
const calHeaderMonth = "flex items-center justify-between gap-1 py-3 px-4 border-b border-neutral-200 bg-neutral-50"
|
||||||
|
|
||||||
|
const calNavBtn = "bg-transparent border-0 p-1 cursor-pointer text-text-muted rounded-sm flex items-center justify-center hover:bg-neutral-100 hover:text-text-body"
|
||||||
|
|
||||||
|
const calMyPicker = "text-sm font-semibold text-text-heading mx-3 whitespace-nowrap"
|
||||||
|
const calMyMonth = "text-lg font-heading mx-4 flex-1 text-center font-semibold text-text-heading whitespace-nowrap"
|
||||||
|
|
||||||
|
const calWeekdaysPicker = "grid grid-cols-7 gap-[2px] mb-1"
|
||||||
|
const calWeekdaysMonth = "grid grid-cols-7 border-b border-neutral-200"
|
||||||
|
const calWeekdayPicker = "text-center text-xs font-semibold text-text-muted p-1"
|
||||||
|
const calWeekdayMonth = "text-center text-xs font-semibold text-text-muted p-2 uppercase tracking-wider"
|
||||||
|
|
||||||
|
const calDaysPicker = "grid grid-cols-7 gap-[2px]"
|
||||||
|
const calDaysMonth = "grid grid-cols-7"
|
||||||
|
|
||||||
|
const calDayPickerBase = "aspect-square flex items-center justify-center text-sm bg-transparent border-0 rounded-sm cursor-pointer text-text-body p-0"
|
||||||
|
const calDayMonthBase = "min-h-[6.5rem] flex flex-col items-stretch justify-start p-1.5 border-r border-b border-neutral-200 text-left gap-1 text-xs bg-transparent cursor-pointer"
|
||||||
|
|
||||||
|
const calSelect = "flex-1 py-1 px-2 text-sm font-semibold border border-neutral-200 rounded-sm bg-white text-text-heading cursor-pointer focus:outline-hidden focus:border-primary"
|
||||||
|
|
||||||
|
// Calendar variants.
|
||||||
|
const (
|
||||||
|
CalendarVariantPicker = "picker"
|
||||||
|
CalendarVariantMonth = "month"
|
||||||
|
)
|
||||||
|
|
||||||
|
// calDaysInMonth returns the number of days in month (day 0 of the next month).
|
||||||
|
func calDaysInMonth(year int, month time.Month) int {
|
||||||
|
return time.Date(year, month+1, 0, 0, 0, 0, 0, time.UTC).Day()
|
||||||
|
}
|
||||||
|
|
||||||
|
// calFirstWeekday returns the weekday of the 1st (Sunday=0 .. Saturday=6),
|
||||||
|
// matching JS getDay().
|
||||||
|
func calFirstWeekday(year int, month time.Month) int {
|
||||||
|
return int(time.Date(year, month, 1, 0, 0, 0, 0, time.UTC).Weekday())
|
||||||
|
}
|
||||||
|
|
||||||
|
// calDateKey formats a date as the "YYYY-MM-DD" key the callbacks emit.
|
||||||
|
func calDateKey(t time.Time) string { return t.Format("2006-01-02") }
|
||||||
|
|
||||||
|
// calParseKey strictly parses a "YYYY-MM-DD" key.
|
||||||
|
//
|
||||||
|
// NOTE: the TSX passes selected/viewMonth through `new Date(...)`, which accepts
|
||||||
|
// many free-form strings; the Go API is documented as "YYYY-MM-DD" so parsing is
|
||||||
|
// narrowed to that layout.
|
||||||
|
func calParseKey(s string) (time.Time, bool) {
|
||||||
|
if s == "" {
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
t, err := time.Parse("2006-01-02", s)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
return t, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func calSameDay(a, b time.Time) bool {
|
||||||
|
return a.Year() == b.Year() && a.Month() == b.Month() && a.Day() == b.Day()
|
||||||
|
}
|
||||||
|
|
||||||
|
// calCell is one grid cell: a day, or an empty leading blank.
|
||||||
|
type calCell struct {
|
||||||
|
date time.Time
|
||||||
|
empty bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// calMonthCells builds the padded month grid (leading blanks + each day).
|
||||||
|
func calMonthCells(year int, month time.Month) []calCell {
|
||||||
|
n := calDaysInMonth(year, month)
|
||||||
|
first := calFirstWeekday(year, month)
|
||||||
|
cells := make([]calCell, 0, first+n)
|
||||||
|
for i := 0; i < first; i++ {
|
||||||
|
cells = append(cells, calCell{empty: true})
|
||||||
|
}
|
||||||
|
for d := 1; d <= n; d++ {
|
||||||
|
cells = append(cells, calCell{date: time.Date(year, month, d, 0, 0, 0, 0, time.UTC)})
|
||||||
|
}
|
||||||
|
return cells
|
||||||
|
}
|
||||||
|
|
||||||
|
// calWeekdaysRow renders the Su..Sa header row for the given grid/cell classes.
|
||||||
|
func calWeekdaysRow(rowCls, cellCls string) *vdom.VNode {
|
||||||
|
mods := []vdom.Mod{vdom.Attr("class", rowCls)}
|
||||||
|
for _, d := range calDays {
|
||||||
|
mods = append(mods, vdom.El("div", vdom.Attr("class", cellCls), vdom.Text(d)))
|
||||||
|
}
|
||||||
|
return vdom.El("div", mods...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func calDayClassPicker(empty, selected, today bool) string {
|
||||||
|
c := calDayPickerBase
|
||||||
|
if empty {
|
||||||
|
c = cx(c, "cursor-default")
|
||||||
|
} else {
|
||||||
|
c = cx(c, "hover:bg-neutral-100")
|
||||||
|
}
|
||||||
|
if today {
|
||||||
|
c = cx(c, "font-bold text-primary")
|
||||||
|
}
|
||||||
|
if selected {
|
||||||
|
c = cx(c, "!bg-primary !text-white")
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// calDayClassMonth mirrors the month-variant class logic. idx%7==6 (last column)
|
||||||
|
// drops the right border since the Tailwind compiler can't do [&:nth-child(7n)].
|
||||||
|
func calDayClassMonth(idx int, empty, selected bool) string {
|
||||||
|
c := calDayMonthBase
|
||||||
|
if idx%7 == 6 {
|
||||||
|
c = cx(c, "border-r-0")
|
||||||
|
}
|
||||||
|
if empty {
|
||||||
|
c = cx(c, "bg-neutral-50 cursor-default")
|
||||||
|
} else {
|
||||||
|
c = cx(c, "hover:bg-neutral-50")
|
||||||
|
}
|
||||||
|
if selected {
|
||||||
|
c = cx(c, "bg-primary/10 text-text-body")
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func calDayNumberClassMonth(today bool) string {
|
||||||
|
if today {
|
||||||
|
return "bg-primary text-white rounded-full w-6 h-6 inline-flex items-center justify-center p-0 self-end text-sm font-semibold"
|
||||||
|
}
|
||||||
|
return "text-sm font-semibold text-text-muted self-end px-1 py-0.5"
|
||||||
|
}
|
||||||
|
|
||||||
|
// calPickerDaysGrid renders the picker-variant day grid. renderDay is optional.
|
||||||
|
// Shared by Calendar (picker) and the DatePicker date-of-birth calendar.
|
||||||
|
func calPickerDaysGrid(view, sel time.Time, hasSel bool, now time.Time, onSelect func(string), renderDay func(string, time.Time) *vdom.VNode) *vdom.VNode {
|
||||||
|
cells := calMonthCells(view.Year(), view.Month())
|
||||||
|
mods := []vdom.Mod{vdom.Attr("class", calDaysPicker)}
|
||||||
|
for _, cell := range cells {
|
||||||
|
selected := hasSel && !cell.empty && calSameDay(cell.date, sel)
|
||||||
|
today := !cell.empty && calSameDay(cell.date, now)
|
||||||
|
btnMods := []vdom.Mod{
|
||||||
|
vdom.Attr("type", "button"),
|
||||||
|
vdom.Attr("class", calDayClassPicker(cell.empty, selected, today)),
|
||||||
|
}
|
||||||
|
if cell.empty {
|
||||||
|
btnMods = append(btnMods, vdom.Attr("disabled", "disabled"))
|
||||||
|
} else {
|
||||||
|
d := cell.date
|
||||||
|
btnMods = append(btnMods, vdom.On(vdom.EVENT_CLICK, func() {
|
||||||
|
if onSelect != nil {
|
||||||
|
onSelect(calDateKey(d))
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
num := ""
|
||||||
|
if !cell.empty {
|
||||||
|
num = strconv.Itoa(cell.date.Day())
|
||||||
|
}
|
||||||
|
btnMods = append(btnMods, vdom.El("span", vdom.Attr("class", "leading-none"), vdom.Text(num)))
|
||||||
|
if !cell.empty && renderDay != nil {
|
||||||
|
if extra := renderDay(calDateKey(cell.date), cell.date); extra != nil {
|
||||||
|
btnMods = append(btnMods, extra)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mods = append(mods, vdom.El("button", btnMods...))
|
||||||
|
}
|
||||||
|
return vdom.El("div", mods...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// calMonthDaysGrid renders the month-variant day grid.
|
||||||
|
func calMonthDaysGrid(view, sel time.Time, hasSel bool, now time.Time, onSelect func(string), renderDay func(string, time.Time) *vdom.VNode) *vdom.VNode {
|
||||||
|
cells := calMonthCells(view.Year(), view.Month())
|
||||||
|
mods := []vdom.Mod{vdom.Attr("class", calDaysMonth)}
|
||||||
|
for idx, cell := range cells {
|
||||||
|
selected := hasSel && !cell.empty && calSameDay(cell.date, sel)
|
||||||
|
today := !cell.empty && calSameDay(cell.date, now)
|
||||||
|
btnMods := []vdom.Mod{
|
||||||
|
vdom.Attr("type", "button"),
|
||||||
|
vdom.Attr("class", calDayClassMonth(idx, cell.empty, selected)),
|
||||||
|
}
|
||||||
|
if cell.empty {
|
||||||
|
btnMods = append(btnMods, vdom.Attr("disabled", "disabled"))
|
||||||
|
} else {
|
||||||
|
d := cell.date
|
||||||
|
btnMods = append(btnMods, vdom.On(vdom.EVENT_CLICK, func() {
|
||||||
|
if onSelect != nil {
|
||||||
|
onSelect(calDateKey(d))
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
num := ""
|
||||||
|
if !cell.empty {
|
||||||
|
num = strconv.Itoa(cell.date.Day())
|
||||||
|
}
|
||||||
|
btnMods = append(btnMods, vdom.El("span", vdom.Attr("class", calDayNumberClassMonth(today)), vdom.Text(num)))
|
||||||
|
if !cell.empty && renderDay != nil {
|
||||||
|
if extra := renderDay(calDateKey(cell.date), cell.date); extra != nil {
|
||||||
|
btnMods = append(btnMods, extra)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mods = append(mods, vdom.El("button", btnMods...))
|
||||||
|
}
|
||||||
|
return vdom.El("div", mods...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CalendarProps configures Calendar. It is a controlled component: the visible
|
||||||
|
// month is a plain value (ViewMonth) driven by the OnNavigate callback, and the
|
||||||
|
// selection is a "YYYY-MM-DD" key driven by OnSelect.
|
||||||
|
//
|
||||||
|
// NOTE: the TSX kept ViewMonth in an internal signal synced from props via
|
||||||
|
// effects, plus attr:key / _reset re-render hacks. Those have no equivalent in
|
||||||
|
// the neutral runtime; the month is modeled as an explicit prop + callback.
|
||||||
|
type CalendarProps struct {
|
||||||
|
Selected string // selected date "YYYY-MM-DD" (empty = none)
|
||||||
|
ViewMonth time.Time // visible month (any day within it); zero => Selected's month, else today
|
||||||
|
Variant string // "picker" (default) or "month"
|
||||||
|
OnSelect func(key string) // clicked day, as "YYYY-MM-DD"
|
||||||
|
OnNavigate func(month time.Time) // first-of-month requested by the prev/next buttons
|
||||||
|
RenderDay func(key string, date time.Time) *vdom.VNode // optional per-day content
|
||||||
|
Class string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calendar renders a month grid in either the compact "picker" layout or the
|
||||||
|
// large "month" layout.
|
||||||
|
func Calendar(p CalendarProps) *vdom.VNode {
|
||||||
|
now := time.Now()
|
||||||
|
sel, hasSel := calParseKey(p.Selected)
|
||||||
|
|
||||||
|
view := p.ViewMonth
|
||||||
|
if view.IsZero() {
|
||||||
|
if hasSel {
|
||||||
|
view = sel
|
||||||
|
} else {
|
||||||
|
view = now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isMonth := p.Variant == CalendarVariantMonth
|
||||||
|
|
||||||
|
rootCls := calPickerRoot
|
||||||
|
headerCls := calHeaderPicker
|
||||||
|
myCls := calMyPicker
|
||||||
|
weekdaysCls := calWeekdaysPicker
|
||||||
|
weekdayCls := calWeekdayPicker
|
||||||
|
if isMonth {
|
||||||
|
rootCls = calMonthRoot
|
||||||
|
headerCls = calHeaderMonth
|
||||||
|
myCls = calMyMonth
|
||||||
|
weekdaysCls = calWeekdaysMonth
|
||||||
|
weekdayCls = calWeekdayMonth
|
||||||
|
}
|
||||||
|
|
||||||
|
prev := time.Date(view.Year(), view.Month()-1, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
next := time.Date(view.Year(), view.Month()+1, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
navigate := func(m time.Time) func() {
|
||||||
|
return func() {
|
||||||
|
if p.OnNavigate != nil {
|
||||||
|
p.OnNavigate(m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
header := vdom.El("div", vdom.Attr("class", headerCls),
|
||||||
|
vdom.El("button",
|
||||||
|
vdom.Attr("type", "button"),
|
||||||
|
vdom.Attr("class", calNavBtn),
|
||||||
|
vdom.On(vdom.EVENT_CLICK, navigate(prev)),
|
||||||
|
Icon("chevron-left", 16, ""),
|
||||||
|
),
|
||||||
|
vdom.El("span", vdom.Attr("class", myCls),
|
||||||
|
vdom.Text(calMonths[int(view.Month())-1]+" "+strconv.Itoa(view.Year()))),
|
||||||
|
vdom.El("button",
|
||||||
|
vdom.Attr("type", "button"),
|
||||||
|
vdom.Attr("class", calNavBtn),
|
||||||
|
vdom.On(vdom.EVENT_CLICK, navigate(next)),
|
||||||
|
Icon("chevron-right", 16, ""),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
var daysGrid *vdom.VNode
|
||||||
|
if isMonth {
|
||||||
|
daysGrid = calMonthDaysGrid(view, sel, hasSel, now, p.OnSelect, p.RenderDay)
|
||||||
|
} else {
|
||||||
|
daysGrid = calPickerDaysGrid(view, sel, hasSel, now, p.OnSelect, p.RenderDay)
|
||||||
|
}
|
||||||
|
|
||||||
|
return vdom.El("div",
|
||||||
|
vdom.Attr("class", cx(rootCls, p.Class)),
|
||||||
|
header,
|
||||||
|
calWeekdaysRow(weekdaysCls, weekdayCls),
|
||||||
|
daysGrid,
|
||||||
|
)
|
||||||
|
}
|
||||||
73
go/webui/cards.go
Normal file
73
go/webui/cards.go
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
package webui
|
||||||
|
|
||||||
|
import "kjol/vdom"
|
||||||
|
|
||||||
|
// Port of web/kit/Cards.tsx. The `ui-card`/`no-flex` marker classes are kept so
|
||||||
|
// any page CSS targeting them still applies; baseline styling is Tailwind.
|
||||||
|
|
||||||
|
const cardBase = "ui-card bg-white shadow-sm rounded-default w-full"
|
||||||
|
const cardWithPadding = cardBase + " p-5 flex-1"
|
||||||
|
const cardNoFlex = cardBase + " no-flex p-5"
|
||||||
|
const cardNoPaddingNoFlex = cardBase + " no-padding no-flex"
|
||||||
|
const borderCard = "border border-neutral-300 rounded-default p-5 w-full"
|
||||||
|
|
||||||
|
// cutCornerCard uses two clip-path pseudo-elements to notch opposite corners.
|
||||||
|
const cutCornerCard = "relative isolate p-5 w-full " +
|
||||||
|
"before:content-[''] before:absolute before:inset-0 before:bg-neutral-300 before:-z-20 " +
|
||||||
|
"before:[clip-path:polygon(16px_0,100%_0,100%_calc(100%_-_16px),calc(100%_-_16px)_100%,0_100%,0_16px)] " +
|
||||||
|
"after:content-[''] after:absolute after:inset-[1px] after:bg-white after:-z-10 " +
|
||||||
|
"after:[clip-path:polygon(15px_0,100%_0,100%_calc(100%_-_15px),calc(100%_-_15px)_100%,0_100%,0_15px)]"
|
||||||
|
|
||||||
|
func cardDiv(base, class string, children []*vdom.VNode) *vdom.VNode {
|
||||||
|
return vdom.El("div", kids([]vdom.Mod{vdom.Attr("class", cx(base, class))}, children)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Card is the standard padded, flex-grow card.
|
||||||
|
func Card(class string, children ...*vdom.VNode) *vdom.VNode {
|
||||||
|
return cardDiv(cardWithPadding, class, children)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CardNoPadding is a card with no padding and no flex-grow.
|
||||||
|
func CardNoPadding(class string, children ...*vdom.VNode) *vdom.VNode {
|
||||||
|
return cardDiv(cardNoPaddingNoFlex, class, children)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CardNoFlexGrow is a padded card that does not flex-grow.
|
||||||
|
func CardNoFlexGrow(class string, children ...*vdom.VNode) *vdom.VNode {
|
||||||
|
return cardDiv(cardNoFlex, class, children)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BorderCard is a bordered (shadowless) card.
|
||||||
|
func BorderCard(class string, children ...*vdom.VNode) *vdom.VNode {
|
||||||
|
return cardDiv(borderCard, class, children)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BorderCutCornerCard is a card with two notched corners (clip-path).
|
||||||
|
func BorderCutCornerCard(class string, children ...*vdom.VNode) *vdom.VNode {
|
||||||
|
return cardDiv(cutCornerCard, class, children)
|
||||||
|
}
|
||||||
|
|
||||||
|
const cardHeader = "text-xl tracking-tight text-black mb-5"
|
||||||
|
const cardHeaderHR = "text-neutral-200 mt-1 mb-3"
|
||||||
|
|
||||||
|
// CardHeader renders a card title followed by a divider.
|
||||||
|
func CardHeader(class string, children ...*vdom.VNode) *vdom.VNode {
|
||||||
|
mods := kids([]vdom.Mod{vdom.Attr("class", cx(cardHeader, class))}, children)
|
||||||
|
mods = append(mods, vdom.El("hr", vdom.Attr("class", cardHeaderHR)))
|
||||||
|
return vdom.El("div", mods...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CardHeaderTextCenter is CardHeader, centered.
|
||||||
|
func CardHeaderTextCenter(class string, children ...*vdom.VNode) *vdom.VNode {
|
||||||
|
mods := kids([]vdom.Mod{vdom.Attr("class", cx(cardHeader, "text-center", class))}, children)
|
||||||
|
mods = append(mods, vdom.El("hr", vdom.Attr("class", cardHeaderHR)))
|
||||||
|
return vdom.El("div", mods...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CardSubheader renders a smaller secondary heading.
|
||||||
|
func CardSubheader(class string, children ...*vdom.VNode) *vdom.VNode {
|
||||||
|
return vdom.El("div", kids([]vdom.Mod{vdom.Attr("class", cx("text-lg tracking-tight text-black mb-2", class))}, children)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CardSpacer is vertical spacing between cards.
|
||||||
|
func CardSpacer() *vdom.VNode { return vdom.El("div", vdom.Attr("class", "mb-6")) }
|
||||||
479
go/webui/cellgrid.go
Normal file
479
go/webui/cellgrid.go
Normal file
@@ -0,0 +1,479 @@
|
|||||||
|
package webui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"kjol/vdom"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Port of web/kit/CellGrid.tsx — an editable, sortable spreadsheet-style grid.
|
||||||
|
//
|
||||||
|
// Row model: rows are map[string]any (the clean Go analog of the TSX's `any`
|
||||||
|
// objects with string-keyed fields, so row[col.Key], row[IDField], and conflict
|
||||||
|
// lookups map directly). Column callbacks (Render, SortValue, CellClassFn,
|
||||||
|
// OnClick, Parse) take/return that map so no type assertions are needed.
|
||||||
|
//
|
||||||
|
// NOTE: Several CellGrid behaviors are browser-only and have no equivalent in the
|
||||||
|
// neutral runtime, so they are dropped (the render output and sort/edit/conflict
|
||||||
|
// wiring are preserved):
|
||||||
|
// - Keyboard navigation (Enter/Tab/Arrows/Escape), cell focus & the `selected`
|
||||||
|
// signal, and focusCell/moveSelection: all require live DOM focus()/refs.
|
||||||
|
// - Row-reorder FLIP animation (getBoundingClientRect + Element.animate).
|
||||||
|
// - The imperative CellGridApi `ref` (focusCell/selected/snapshot/animateRows).
|
||||||
|
// Its one pure-logic member, dirty(), is offered as the CellGridDirty helper.
|
||||||
|
// - onFocus/onBlur/blurStamp: conflict sets are recomputed on every render from
|
||||||
|
// the current rows instead (a signal write already re-renders), so duplicate
|
||||||
|
// highlighting stays live without the blur hook.
|
||||||
|
// - localeCompare is approximated by byte-wise strings.Compare.
|
||||||
|
// - The sort caret uses "caret-up"/"caret-down"; those aren't in the default
|
||||||
|
// icon registry, so they render as an empty box unless the app RegisterIcon's
|
||||||
|
// them (the triangle-exclamation conflict marker is registered by default).
|
||||||
|
|
||||||
|
// GridHeaderCls is the base <th> class for CellGrid headers (exported, matching
|
||||||
|
// the TSX GRID_HEADER_CLS).
|
||||||
|
const GridHeaderCls = "border-b border-r border-neutral-300 bg-neutral-50 px-1.5 py-1.5 text-left text-xs font-bold uppercase text-black whitespace-nowrap last:border-r-0"
|
||||||
|
|
||||||
|
var cgLeadingDigits = regexp.MustCompile(`^\d+`)
|
||||||
|
var cgLeadingFloat = regexp.MustCompile(`^[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?`)
|
||||||
|
|
||||||
|
func cellGridStr(v any) string {
|
||||||
|
if v == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return fmt.Sprint(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cellGridColumnSize(width, minWidth string) string {
|
||||||
|
if width != "" {
|
||||||
|
return width
|
||||||
|
}
|
||||||
|
return minWidth
|
||||||
|
}
|
||||||
|
|
||||||
|
func cellGridLeadingInt(s string) (int, bool) {
|
||||||
|
m := cgLeadingDigits.FindString(s)
|
||||||
|
if m == "" {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
n, err := strconv.Atoi(m)
|
||||||
|
if err != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return n, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func cellGridParseFloat(v any) float64 {
|
||||||
|
m := cgLeadingFloat.FindString(strings.TrimSpace(cellGridStr(v)))
|
||||||
|
if m == "" || m == "+" || m == "-" || m == "." {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
f, err := strconv.ParseFloat(m, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompareRowsGeneric compares a[key] and b[key] the way the TSX comparator does:
|
||||||
|
// empties sort last, "numeric" by leading integer then string tiebreak, "money"
|
||||||
|
// by parsed float, otherwise by string. Returns <0, 0, or >0.
|
||||||
|
func CompareRowsGeneric(a, b map[string]any, key, sortType string) int {
|
||||||
|
av := a[key]
|
||||||
|
bv := b[key]
|
||||||
|
aEmpty := av == nil || av == ""
|
||||||
|
bEmpty := bv == nil || bv == ""
|
||||||
|
if aEmpty && bEmpty {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if aEmpty {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
if bEmpty {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
switch sortType {
|
||||||
|
case "numeric":
|
||||||
|
as := cellGridStr(av)
|
||||||
|
bs := cellGridStr(bv)
|
||||||
|
an, aok := cellGridLeadingInt(as)
|
||||||
|
bn, bok := cellGridLeadingInt(bs)
|
||||||
|
if aok && bok {
|
||||||
|
if an != bn {
|
||||||
|
if an < bn {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return strings.Compare(as, bs)
|
||||||
|
}
|
||||||
|
if aok {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
if bok {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return strings.Compare(as, bs)
|
||||||
|
case "money":
|
||||||
|
af := cellGridParseFloat(av)
|
||||||
|
bf := cellGridParseFloat(bv)
|
||||||
|
if af < bf {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
if af > bf {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
default:
|
||||||
|
return strings.Compare(cellGridStr(av), cellGridStr(bv))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CellGridColumn defines one grid column. The `cellClass: string | (row)=>string`
|
||||||
|
// TSX union becomes CellClass (static) + CellClassFn (dynamic; wins when set).
|
||||||
|
type CellGridColumn struct {
|
||||||
|
Key string
|
||||||
|
Label string
|
||||||
|
SortKey string
|
||||||
|
SortType string
|
||||||
|
SortValue func(row map[string]any) any
|
||||||
|
Width string
|
||||||
|
MinWidth string
|
||||||
|
HeaderClass string
|
||||||
|
Editable bool
|
||||||
|
ReadOnly bool
|
||||||
|
Render func(row map[string]any) *vdom.VNode
|
||||||
|
CellClass string
|
||||||
|
CellClassFn func(row map[string]any) string
|
||||||
|
OnClick func(row map[string]any)
|
||||||
|
InputMode string
|
||||||
|
Placeholder string
|
||||||
|
Parse func(value string) any
|
||||||
|
}
|
||||||
|
|
||||||
|
// CellGridProps configures CellGrid. SortKey/SortDesc are the current sort state;
|
||||||
|
// SetSortKey/SetSortDesc are called by the header click handler (which toggles
|
||||||
|
// direction when the same column is re-clicked).
|
||||||
|
type CellGridProps struct {
|
||||||
|
Columns []CellGridColumn
|
||||||
|
Rows []map[string]any
|
||||||
|
IDField string
|
||||||
|
OnCellChange func(rowID any, field string, value any)
|
||||||
|
SortKey string
|
||||||
|
SetSortKey func(key string)
|
||||||
|
SortDesc bool
|
||||||
|
SetSortDesc func(desc bool)
|
||||||
|
ConflictFields []string
|
||||||
|
Dense bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// SortableHeaderProps configures a clickable, sort-indicating header cell.
|
||||||
|
type SortableHeaderProps struct {
|
||||||
|
Label string
|
||||||
|
SortKey string
|
||||||
|
Width string
|
||||||
|
MinWidth string
|
||||||
|
Current string // currently active sort key ("" == none)
|
||||||
|
Desc bool
|
||||||
|
OnSort func(key string)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SortableHeader renders a <th> that shows a caret when it is the active sort
|
||||||
|
// column and calls OnSort(SortKey) on click.
|
||||||
|
func SortableHeader(p SortableHeaderProps) *vdom.VNode {
|
||||||
|
isActive := p.SortKey != "" && p.Current == p.SortKey
|
||||||
|
cls := GridHeaderCls + " cursor-pointer select-none hover:bg-neutral-200"
|
||||||
|
if sz := cellGridColumnSize(p.Width, p.MinWidth); sz != "" {
|
||||||
|
cls += " " + sz
|
||||||
|
}
|
||||||
|
|
||||||
|
inner := []vdom.Mod{
|
||||||
|
vdom.Attr("class", "flex items-center gap-0.5 min-w-0"),
|
||||||
|
vdom.El("span", vdom.Attr("class", "truncate min-w-0 flex-1"), vdom.Text(p.Label)),
|
||||||
|
}
|
||||||
|
if isActive {
|
||||||
|
icon := "caret-up"
|
||||||
|
if p.Desc {
|
||||||
|
icon = "caret-down"
|
||||||
|
}
|
||||||
|
inner = append(inner, vdom.El("span", vdom.Attr("class", "shrink-0"), Icon(icon, 10, "")))
|
||||||
|
}
|
||||||
|
|
||||||
|
mods := []vdom.Mod{vdom.Attr("class", cls)}
|
||||||
|
if p.OnSort != nil {
|
||||||
|
key := p.SortKey
|
||||||
|
mods = append(mods, vdom.On(vdom.EVENT_CLICK, func() { p.OnSort(key) }))
|
||||||
|
}
|
||||||
|
mods = append(mods, vdom.El("div", inner...))
|
||||||
|
return vdom.El("th", mods...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cellGridConflictSets(p CellGridProps) map[string]map[string]bool {
|
||||||
|
result := map[string]map[string]bool{}
|
||||||
|
if len(p.ConflictFields) == 0 {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
for _, field := range p.ConflictFields {
|
||||||
|
counts := map[string]int{}
|
||||||
|
for _, row := range p.Rows {
|
||||||
|
v := row[field]
|
||||||
|
if v == nil || v == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
counts[cellGridStr(v)]++
|
||||||
|
}
|
||||||
|
set := map[string]bool{}
|
||||||
|
for v, c := range counts {
|
||||||
|
if c > 1 {
|
||||||
|
set[v] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result[field] = set
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func cellGridSortedRows(p CellGridProps) []map[string]any {
|
||||||
|
rows := make([]map[string]any, len(p.Rows))
|
||||||
|
copy(rows, p.Rows)
|
||||||
|
sk := p.SortKey
|
||||||
|
if sk == "" {
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
var col *CellGridColumn
|
||||||
|
for i := range p.Columns {
|
||||||
|
if p.Columns[i].SortKey == sk {
|
||||||
|
col = &p.Columns[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
st := "string"
|
||||||
|
getVal := func(r map[string]any) any { return r[sk] }
|
||||||
|
if col != nil {
|
||||||
|
if col.SortType != "" {
|
||||||
|
st = col.SortType
|
||||||
|
}
|
||||||
|
if col.SortValue != nil {
|
||||||
|
getVal = col.SortValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort ascending stably, then reverse for desc (matches the TSX, which
|
||||||
|
// reverses after a stable ascending sort rather than flipping ties).
|
||||||
|
sort.SliceStable(rows, func(i, j int) bool {
|
||||||
|
a := map[string]any{"v": getVal(rows[i])}
|
||||||
|
b := map[string]any{"v": getVal(rows[j])}
|
||||||
|
return CompareRowsGeneric(a, b, "v", st) < 0
|
||||||
|
})
|
||||||
|
if p.SortDesc {
|
||||||
|
for i, j := 0, len(rows)-1; i < j; i, j = i+1, j-1 {
|
||||||
|
rows[i], rows[j] = rows[j], rows[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
// CellGridDirty reports whether any editable field differs from initialRows (the
|
||||||
|
// pure-logic half of the dropped CellGridApi.dirty()). A length mismatch or a nil
|
||||||
|
// baseline counts as dirty.
|
||||||
|
func CellGridDirty(columns []CellGridColumn, rows, initialRows []map[string]any) bool {
|
||||||
|
if initialRows == nil || len(rows) != len(initialRows) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
var fields []string
|
||||||
|
for _, c := range columns {
|
||||||
|
if c.Editable {
|
||||||
|
fields = append(fields, c.Key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := range rows {
|
||||||
|
for _, f := range fields {
|
||||||
|
if cellGridStr(rows[i][f]) != cellGridStr(initialRows[i][f]) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// CellGrid renders the sortable/editable grid. See the file NOTE for the
|
||||||
|
// browser-only behaviors that are intentionally omitted.
|
||||||
|
func CellGrid(p CellGridProps) *vdom.VNode {
|
||||||
|
idf := p.IDField
|
||||||
|
if idf == "" {
|
||||||
|
idf = "id"
|
||||||
|
}
|
||||||
|
rowHCls := "h-8"
|
||||||
|
if p.Dense {
|
||||||
|
rowHCls = "h-6"
|
||||||
|
}
|
||||||
|
readonlyTdCls := "border-b border-r border-neutral-300 bg-black/5 px-2 text-neutral-700 align-middle " + rowHCls
|
||||||
|
const editableTdCls = "border-b border-r border-neutral-300 p-0 relative align-middle"
|
||||||
|
const inputCls = "absolute inset-0 w-full border-none outline-none bg-transparent px-2 placeholder:text-neutral-400 focus:bg-red-50 focus:shadow-[inset_0_0_0_2px_var(--color-red-500)]"
|
||||||
|
|
||||||
|
conflicts := cellGridConflictSets(p)
|
||||||
|
isConflict := func(field string, value any) bool {
|
||||||
|
if value == nil || value == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
set := conflicts[field]
|
||||||
|
return set != nil && set[cellGridStr(value)]
|
||||||
|
}
|
||||||
|
fieldInConflictList := func(key string) bool {
|
||||||
|
for _, f := range p.ConflictFields {
|
||||||
|
if f == key {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
handleSort := func(key string) {
|
||||||
|
if p.SortKey == key {
|
||||||
|
if p.SetSortDesc != nil {
|
||||||
|
p.SetSortDesc(!p.SortDesc)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if p.SetSortKey != nil {
|
||||||
|
p.SetSortKey(key)
|
||||||
|
}
|
||||||
|
if p.SetSortDesc != nil {
|
||||||
|
p.SetSortDesc(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderHeader := func(col CellGridColumn) *vdom.VNode {
|
||||||
|
if col.SortKey != "" {
|
||||||
|
return SortableHeader(SortableHeaderProps{
|
||||||
|
Label: col.Label,
|
||||||
|
SortKey: col.SortKey,
|
||||||
|
Width: col.Width,
|
||||||
|
MinWidth: col.MinWidth,
|
||||||
|
Current: p.SortKey,
|
||||||
|
Desc: p.SortDesc,
|
||||||
|
OnSort: handleSort,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
cls := GridHeaderCls
|
||||||
|
if col.HeaderClass != "" {
|
||||||
|
cls += " " + col.HeaderClass
|
||||||
|
}
|
||||||
|
if sz := cellGridColumnSize(col.Width, col.MinWidth); sz != "" {
|
||||||
|
cls += " " + sz
|
||||||
|
}
|
||||||
|
return vdom.El("th", vdom.Attr("class", cls), vdom.Text(col.Label))
|
||||||
|
}
|
||||||
|
|
||||||
|
renderCell := func(row map[string]any, col CellGridColumn) *vdom.VNode {
|
||||||
|
rowID := row[idf]
|
||||||
|
sizeCls := ""
|
||||||
|
if sz := cellGridColumnSize(col.Width, col.MinWidth); sz != "" {
|
||||||
|
sizeCls = " " + sz
|
||||||
|
}
|
||||||
|
cellClass := func() string {
|
||||||
|
if col.CellClassFn != nil {
|
||||||
|
return col.CellClassFn(row) + sizeCls
|
||||||
|
}
|
||||||
|
base := col.CellClass
|
||||||
|
if base == "" {
|
||||||
|
base = readonlyTdCls
|
||||||
|
}
|
||||||
|
return base + sizeCls
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom-rendered, non-editable cell.
|
||||||
|
if col.Render != nil && !col.Editable {
|
||||||
|
mods := []vdom.Mod{vdom.Attr("class", cellClass())}
|
||||||
|
if col.OnClick != nil {
|
||||||
|
mods = append(mods, vdom.On(vdom.EVENT_CLICK, func() { col.OnClick(row) }))
|
||||||
|
}
|
||||||
|
mods = append(mods, col.Render(row))
|
||||||
|
return vdom.El("td", mods...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read-only cell.
|
||||||
|
if col.ReadOnly {
|
||||||
|
mods := []vdom.Mod{vdom.Attr("class", cellClass())}
|
||||||
|
if col.OnClick != nil {
|
||||||
|
mods = append(mods, vdom.On(vdom.EVENT_CLICK, func() { col.OnClick(row) }))
|
||||||
|
}
|
||||||
|
if col.Render != nil {
|
||||||
|
mods = append(mods, col.Render(row))
|
||||||
|
} else {
|
||||||
|
mods = append(mods, vdom.Text(cellGridStr(row[col.Key])))
|
||||||
|
}
|
||||||
|
return vdom.El("td", mods...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Editable cell.
|
||||||
|
inList := fieldInConflictList(col.Key)
|
||||||
|
hasConflict := isConflict(col.Key, row[col.Key])
|
||||||
|
tdCls := editableTdCls + sizeCls
|
||||||
|
if inList {
|
||||||
|
tdCls += " relative"
|
||||||
|
if hasConflict {
|
||||||
|
tdCls += " bg-amber-100"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
im := col.InputMode
|
||||||
|
if im == "" {
|
||||||
|
im = "text"
|
||||||
|
}
|
||||||
|
input := vdom.El("input",
|
||||||
|
vdom.Attr("class", inputCls),
|
||||||
|
vdom.Attr("type", "text"),
|
||||||
|
vdom.Attr("inputmode", im),
|
||||||
|
vdom.Attr("placeholder", col.Placeholder),
|
||||||
|
vdom.Prop("value", cellGridStr(row[col.Key])),
|
||||||
|
vdom.OnEvent(vdom.EVENT_INPUT, func(e vdom.Event) {
|
||||||
|
var v any = e.Value()
|
||||||
|
if col.Parse != nil {
|
||||||
|
v = col.Parse(e.Value())
|
||||||
|
}
|
||||||
|
if p.OnCellChange != nil {
|
||||||
|
p.OnCellChange(rowID, col.Key, v)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
mods := []vdom.Mod{vdom.Attr("class", tdCls), input}
|
||||||
|
if inList && hasConflict {
|
||||||
|
mods = append(mods, vdom.El("span",
|
||||||
|
vdom.Attr("class", "pointer-events-none absolute right-0.5 top-1/2 -translate-y-1/2 text-amber-600"),
|
||||||
|
vdom.Attr("title", "Duplicate value"),
|
||||||
|
Icon("triangle-exclamation", 12, ""),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
return vdom.El("td", mods...)
|
||||||
|
}
|
||||||
|
|
||||||
|
var headerCells []*vdom.VNode
|
||||||
|
for _, col := range p.Columns {
|
||||||
|
headerCells = append(headerCells, renderHeader(col))
|
||||||
|
}
|
||||||
|
thead := vdom.El("thead", vdom.El("tr", kids(nil, headerCells)...))
|
||||||
|
|
||||||
|
var bodyRows []*vdom.VNode
|
||||||
|
for _, row := range cellGridSortedRows(p) {
|
||||||
|
var cells []*vdom.VNode
|
||||||
|
for _, col := range p.Columns {
|
||||||
|
cells = append(cells, renderCell(row, col))
|
||||||
|
}
|
||||||
|
bodyRows = append(bodyRows, vdom.El("tr",
|
||||||
|
kids([]vdom.Mod{vdom.Attr("class", "odd:bg-white even:bg-neutral-100")}, cells)...))
|
||||||
|
}
|
||||||
|
tbody := vdom.El("tbody", kids(nil, bodyRows)...)
|
||||||
|
|
||||||
|
tableCls := "min-w-full w-max border-collapse text-sm"
|
||||||
|
if p.Dense {
|
||||||
|
tableCls = "min-w-full w-max border-collapse text-xs"
|
||||||
|
}
|
||||||
|
return vdom.El("div",
|
||||||
|
vdom.Attr("class", "relative max-w-full overflow-x-auto border border-neutral-300 rounded-default bg-white tabular-nums"),
|
||||||
|
vdom.El("table", vdom.Attr("class", tableCls), thead, tbody),
|
||||||
|
)
|
||||||
|
}
|
||||||
44
go/webui/chart.go
Normal file
44
go/webui/chart.go
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
package webui
|
||||||
|
|
||||||
|
import "kjol/vdom"
|
||||||
|
|
||||||
|
// Port of web/kit/Chart.tsx (default export ReactiveChart).
|
||||||
|
//
|
||||||
|
// NOTE: The TSX wraps chart.js — it imperatively creates a `new Chart(ctx, cfg)`
|
||||||
|
// against a <canvas> 2D context inside onMount, and pushes new data/options via
|
||||||
|
// createEffect. None of that (canvas 2D drawing, a JS charting lib, mount/cleanup
|
||||||
|
// lifecycles) exists in the neutral gowasm runtime, so actual chart DRAWING is out
|
||||||
|
// of scope here. This port renders the faithful container structure + Tailwind and
|
||||||
|
// an empty <canvas>, keeping a props shape for API parity. The example app instead
|
||||||
|
// draws charts server-side as go-chart SVG.
|
||||||
|
|
||||||
|
// Chart type identifiers (chart.js `type`), kept for API parity with the TSX
|
||||||
|
// ChartType union. Unused by this SVG-less container.
|
||||||
|
const (
|
||||||
|
ChartTypeLine = "line"
|
||||||
|
ChartTypeBar = "bar"
|
||||||
|
ChartTypeRadar = "radar"
|
||||||
|
ChartTypeDoughnut = "doughnut"
|
||||||
|
ChartTypePolarArea = "polarArea"
|
||||||
|
ChartTypeBubble = "bubble"
|
||||||
|
ChartTypePie = "pie"
|
||||||
|
ChartTypeScatter = "scatter"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ReactiveChartProps mirrors the TSX props. Data and Options are accepted for API
|
||||||
|
// parity but are not rendered (no JS chart lib in the neutral runtime; see NOTE).
|
||||||
|
type ReactiveChartProps struct {
|
||||||
|
Type string
|
||||||
|
Data any
|
||||||
|
Options any
|
||||||
|
Class string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReactiveChart renders the chart container (h-full + user class) wrapping an
|
||||||
|
// empty <canvas>. Drawing is out of scope — see the file NOTE.
|
||||||
|
func ReactiveChart(p ReactiveChartProps) *vdom.VNode {
|
||||||
|
return vdom.El("div",
|
||||||
|
vdom.Attr("class", cx("h-full", p.Class)),
|
||||||
|
vdom.El("canvas"),
|
||||||
|
)
|
||||||
|
}
|
||||||
138
go/webui/crmtabs.go
Normal file
138
go/webui/crmtabs.go
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
package webui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"kjol/vdom"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Port of web/kit/CrmTabs.tsx.
|
||||||
|
//
|
||||||
|
// CrmTabGroup / CrmSubTabGroup are behaviourally-identical siblings of TabGroup
|
||||||
|
// with a different look: CrmTabGroup is boxed tabs with a sky-blue top accent;
|
||||||
|
// CrmSubTabGroup is a segmented control. Content sits flat beneath the tab row.
|
||||||
|
//
|
||||||
|
// NOTE: As in Tabs.tsx, the TSX localStorage persistence (storageKey) and
|
||||||
|
// cross-component "storage"-event syncing are browser-only and dropped;
|
||||||
|
// selection collapses to a plain ActiveIndex value + OnTabChange callback (the
|
||||||
|
// caller owns state). The TSX defaultIndex prop collapses into ActiveIndex.
|
||||||
|
|
||||||
|
// CrmTabItem is one CRM tab: title, optional numeric badge (shown when > 0), and
|
||||||
|
// inline panel content.
|
||||||
|
type CrmTabItem struct {
|
||||||
|
Title string
|
||||||
|
Badge int
|
||||||
|
Content *vdom.VNode
|
||||||
|
}
|
||||||
|
|
||||||
|
// CrmTabGroupProps configures CrmTabGroup / CrmSubTabGroup. ActiveIndex is the
|
||||||
|
// selected tab; clicking a tab calls OnTabChange with its index.
|
||||||
|
type CrmTabGroupProps struct {
|
||||||
|
Items []CrmTabItem
|
||||||
|
ActiveIndex int
|
||||||
|
OnTabChange func(int)
|
||||||
|
}
|
||||||
|
|
||||||
|
// crmTabsPanels renders one wrapper div per item; only the active one is shown.
|
||||||
|
func crmTabsPanels(p CrmTabGroupProps) []*vdom.VNode {
|
||||||
|
out := make([]*vdom.VNode, 0, len(p.Items))
|
||||||
|
for i, item := range p.Items {
|
||||||
|
cls := "hidden"
|
||||||
|
if i == p.ActiveIndex {
|
||||||
|
cls = ""
|
||||||
|
}
|
||||||
|
out = append(out, vdom.El("div", vdom.Attr("class", cls), item.Content))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- CrmTabGroup: boxed top-accent tabs -------------------------------------
|
||||||
|
|
||||||
|
const crmTabRow = "flex w-full overflow-x-auto text-sm"
|
||||||
|
const crmTabBase = "flex items-center gap-1.5 cursor-pointer p-4 font-medium border-neutral-300 transition-colors"
|
||||||
|
const crmTabInactive = "border-b text-neutral-500 hover:text-neutral-800"
|
||||||
|
const crmTabActive = "border-x border-t-2 border-t-sky-700 text-primary"
|
||||||
|
const crmTabBadge = "inline-flex items-center justify-center min-w-5 h-5 px-1 text-xs font-semibold bg-primary text-white rounded-full"
|
||||||
|
|
||||||
|
// CrmTabGroup renders boxed tabs with a sky-blue top accent; content sits flat
|
||||||
|
// beneath the row (no body panel), with a trailing filler extending the baseline.
|
||||||
|
func CrmTabGroup(p CrmTabGroupProps) *vdom.VNode {
|
||||||
|
row := []vdom.Mod{vdom.Attr("class", crmTabRow)}
|
||||||
|
for i, item := range p.Items {
|
||||||
|
idx := i
|
||||||
|
state := crmTabInactive
|
||||||
|
if i == p.ActiveIndex {
|
||||||
|
state = crmTabActive
|
||||||
|
}
|
||||||
|
btn := []vdom.Mod{
|
||||||
|
vdom.Attr("type", "button"),
|
||||||
|
vdom.Attr("class", cx(crmTabBase, state)),
|
||||||
|
vdom.On(vdom.EVENT_CLICK, func() {
|
||||||
|
if p.OnTabChange != nil {
|
||||||
|
p.OnTabChange(idx)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
vdom.Text(item.Title),
|
||||||
|
}
|
||||||
|
if item.Badge > 0 {
|
||||||
|
btn = append(btn, vdom.El("span", vdom.Attr("class", crmTabBadge), vdom.Text(strconv.Itoa(item.Badge))))
|
||||||
|
}
|
||||||
|
row = append(row, vdom.El("button", btn...))
|
||||||
|
}
|
||||||
|
row = append(row, vdom.El("div", vdom.Attr("class", "flex-1 border-b border-neutral-300")))
|
||||||
|
|
||||||
|
return vdom.El("div",
|
||||||
|
vdom.Attr("class", "w-full"),
|
||||||
|
vdom.El("div", row...),
|
||||||
|
vdom.El("div", kids(nil, crmTabsPanels(p))...),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- CrmSubTabGroup: segmented control --------------------------------------
|
||||||
|
|
||||||
|
const crmSubTabWrap = "flex pb-3 border-b border-neutral-300 overflow-x-auto"
|
||||||
|
const crmSubTabGroup = "inline-flex items-stretch rounded-md border border-neutral-300 overflow-hidden text-sm select-none"
|
||||||
|
const crmSubTabBase = "flex items-center gap-1.5 py-1 px-3 cursor-pointer font-medium whitespace-nowrap transition-colors"
|
||||||
|
const crmSubTabDivider = "border-l border-neutral-300"
|
||||||
|
const crmSubTabActive = "bg-neutral-500 text-white"
|
||||||
|
const crmSubTabInactive = "bg-white text-neutral-600 hover:bg-neutral-100 hover:text-neutral-900"
|
||||||
|
const crmSubTabBadge = "inline-flex items-center justify-center min-w-5 h-5 px-1 text-xs font-semibold bg-black/10 text-current rounded-full"
|
||||||
|
|
||||||
|
// CrmSubTabGroup renders a left-aligned segmented control (interlocking
|
||||||
|
// segments) with a full-width baseline separating the bar from the content.
|
||||||
|
func CrmSubTabGroup(p CrmTabGroupProps) *vdom.VNode {
|
||||||
|
group := []vdom.Mod{vdom.Attr("class", crmSubTabGroup)}
|
||||||
|
for i, item := range p.Items {
|
||||||
|
idx := i
|
||||||
|
divider := ""
|
||||||
|
if i > 0 {
|
||||||
|
divider = crmSubTabDivider
|
||||||
|
}
|
||||||
|
state := crmSubTabInactive
|
||||||
|
if i == p.ActiveIndex {
|
||||||
|
state = crmSubTabActive
|
||||||
|
}
|
||||||
|
btn := []vdom.Mod{
|
||||||
|
vdom.Attr("type", "button"),
|
||||||
|
vdom.Attr("class", cx(crmSubTabBase, divider, state)),
|
||||||
|
vdom.On(vdom.EVENT_CLICK, func() {
|
||||||
|
if p.OnTabChange != nil {
|
||||||
|
p.OnTabChange(idx)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
vdom.Text(item.Title),
|
||||||
|
}
|
||||||
|
if item.Badge > 0 {
|
||||||
|
btn = append(btn, vdom.El("span", vdom.Attr("class", crmSubTabBadge), vdom.Text(strconv.Itoa(item.Badge))))
|
||||||
|
}
|
||||||
|
group = append(group, vdom.El("button", btn...))
|
||||||
|
}
|
||||||
|
return vdom.El("div",
|
||||||
|
vdom.Attr("class", "w-full pt-3"),
|
||||||
|
vdom.El("div",
|
||||||
|
vdom.Attr("class", crmSubTabWrap),
|
||||||
|
vdom.El("div", group...),
|
||||||
|
),
|
||||||
|
vdom.El("div", kids([]vdom.Mod{vdom.Attr("class", "pt-3")}, crmTabsPanels(p))...),
|
||||||
|
)
|
||||||
|
}
|
||||||
307
go/webui/datepicker.go
Normal file
307
go/webui/datepicker.go
Normal file
@@ -0,0 +1,307 @@
|
|||||||
|
// Port of web/kit/DatePicker.tsx. Date math uses the stdlib time package.
|
||||||
|
package webui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"kjol/vdom"
|
||||||
|
)
|
||||||
|
|
||||||
|
const datePickerWrap = "relative w-full min-w-0"
|
||||||
|
const datePickerField = "relative w-full min-w-0 cursor-pointer [&_.ui-form]:m-0 [&_input]:cursor-text"
|
||||||
|
const datePickerDropdown = "bg-white border border-neutral-200 rounded-default shadow-[0_4px_12px_rgba(0,0,0,0.15)] p-1 min-w-[16rem]"
|
||||||
|
const datePickerIconBtn = "absolute inset-y-0 right-0 z-[1] flex items-center justify-center bg-transparent border-0 px-2 cursor-pointer text-text-muted leading-none hover:text-text-body pointer-events-auto"
|
||||||
|
const datePickerClearBtn = "absolute inset-y-0 right-8 z-[1] flex items-center justify-center bg-transparent border-0 px-1.5 cursor-pointer text-text-muted leading-none hover:text-text-body pointer-events-auto"
|
||||||
|
|
||||||
|
// NOTE: the TSX renders the dropdown into a <Portal> with fixed coordinates from
|
||||||
|
// getBoundingClientRect (tracked each frame). Portals, refs and element
|
||||||
|
// measurement have no equivalent here, so the dropdown is rendered inline and
|
||||||
|
// positioned with static Tailwind (absolute, below the field).
|
||||||
|
const datePickerDropdownPos = "absolute left-0 top-full mt-1 z-[200]"
|
||||||
|
|
||||||
|
// datePickerInputBase mirrors Forms.tsx INPUT_BASE (light, no error/success).
|
||||||
|
const datePickerInputBase = "bg-white block w-full border rounded-default shadow-xs text-sm focus:outline-2 focus:outline-offset-1 disabled:bg-neutral-100 disabled:cursor-not-allowed"
|
||||||
|
|
||||||
|
// datepickerInputCls reproduces Forms.tsx _inputCls for the light, no-state case.
|
||||||
|
func datepickerInputCls(small bool, extra string) string {
|
||||||
|
h, pad := "h-[38px]", "p-2"
|
||||||
|
if small {
|
||||||
|
h, pad = "h-[30px]", "p-1"
|
||||||
|
}
|
||||||
|
return cx(datePickerInputBase, h, pad, "border-neutral-300 focus:outline-sky-500", extra)
|
||||||
|
}
|
||||||
|
|
||||||
|
// datepickerParseInput parses typed/pasted text into a "YYYY-MM-DD" key, or "".
|
||||||
|
//
|
||||||
|
// NOTE: the TSX first tries YYYY-MM-DD, then falls back to the very lenient
|
||||||
|
// `new Date(text)`. Go has no equivalent, so a fixed set of common layouts is
|
||||||
|
// tried instead.
|
||||||
|
func datepickerParseInput(text string) string {
|
||||||
|
s := strings.TrimSpace(text)
|
||||||
|
if s == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for _, layout := range []string{"2006-01-02", "1/2/2006", "01/02/2006", "2006/01/02", "January 2, 2006", "Jan 2, 2006"} {
|
||||||
|
if t, err := time.Parse(layout, s); err == nil {
|
||||||
|
return t.Format("2006-01-02")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// datepickerFormatDisplay renders a "YYYY-MM-DD" key for display.
|
||||||
|
//
|
||||||
|
// NOTE: the TSX uses Date.toLocaleDateString() (locale-dependent). Go has no
|
||||||
|
// locale formatter, so this approximates the common en-US "M/D/YYYY" form.
|
||||||
|
func datepickerFormatDisplay(iso string) string {
|
||||||
|
t, ok := calParseKey(iso)
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d/%d/%d", int(t.Month()), t.Day(), t.Year())
|
||||||
|
}
|
||||||
|
|
||||||
|
// datepickerInput renders the FormInput approximation: a .ui-form wrapper around
|
||||||
|
// a styled <input>. onChange commits the parsed value on the native change event.
|
||||||
|
//
|
||||||
|
// NOTE: the TSX FormInput tracks an editing/draft state machine across
|
||||||
|
// focus/input/blur. Without persistent local signals that collapses to a single
|
||||||
|
// onchange commit; the input shows the formatted external value otherwise.
|
||||||
|
func datepickerInput(value, placeholder string, small bool, extra string, onChange func(string)) *vdom.VNode {
|
||||||
|
mods := []vdom.Mod{
|
||||||
|
vdom.Attr("type", "text"),
|
||||||
|
vdom.Attr("class", datepickerInputCls(small, extra)),
|
||||||
|
vdom.Attr("placeholder", placeholder),
|
||||||
|
vdom.Prop("value", value),
|
||||||
|
}
|
||||||
|
if onChange != nil {
|
||||||
|
mods = append(mods, vdom.OnEvent(vdom.EVENT_CHANGE, func(e vdom.Event) {
|
||||||
|
onChange(datepickerParseInput(e.Value()))
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
return vdom.El("div", vdom.Attr("class", "ui-form"), vdom.El("input", mods...))
|
||||||
|
}
|
||||||
|
|
||||||
|
func datepickerIconButton(onClick func()) *vdom.VNode {
|
||||||
|
return vdom.El("button",
|
||||||
|
vdom.Attr("type", "button"),
|
||||||
|
vdom.Attr("class", datePickerIconBtn),
|
||||||
|
vdom.Attr("aria-label", "Open calendar"),
|
||||||
|
vdom.On(vdom.EVENT_CLICK, onClick),
|
||||||
|
Icon("calendar", 16, "block leading-none"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DatePickerProps configures DatePicker and DateOfBirthPicker. It is controlled:
|
||||||
|
// Value is the "YYYY-MM-DD" selection, Open is the dropdown state, ViewMonth is
|
||||||
|
// the calendar's visible month — each paired with a callback.
|
||||||
|
//
|
||||||
|
// NOTE: the TSX owns open/editing/localValue/dropdownPos in internal signals and
|
||||||
|
// closes the dropdown via a document click listener. Outside-click handling,
|
||||||
|
// stopPropagation and computed positioning aren't representable in the neutral
|
||||||
|
// runtime, so open/selection/month are lifted to props + callbacks.
|
||||||
|
type DatePickerProps struct {
|
||||||
|
Value string // selected date "YYYY-MM-DD"
|
||||||
|
OnChange func(value string) // new selection (or "" when cleared)
|
||||||
|
Placeholder string // input placeholder
|
||||||
|
Small bool // compact input height
|
||||||
|
Clearable bool // show a clear (x) button when a value is set
|
||||||
|
Open bool // dropdown open state (controlled)
|
||||||
|
OnToggle func(open bool) // request to open/close the dropdown
|
||||||
|
ViewMonth time.Time // visible month of the dropdown calendar
|
||||||
|
OnNavigate func(month time.Time) // prev/next month requested in the dropdown
|
||||||
|
}
|
||||||
|
|
||||||
|
// DatePicker renders a text field with a calendar dropdown (picker layout).
|
||||||
|
func DatePicker(p DatePickerProps) *vdom.VNode {
|
||||||
|
hasValue := p.Value != ""
|
||||||
|
|
||||||
|
extra := "w-full pr-9"
|
||||||
|
if p.Clearable && hasValue {
|
||||||
|
extra = "w-full pr-14"
|
||||||
|
}
|
||||||
|
|
||||||
|
fieldMods := []vdom.Mod{
|
||||||
|
vdom.Attr("class", datePickerField),
|
||||||
|
vdom.On(vdom.EVENT_CLICK, func() {
|
||||||
|
if p.OnToggle != nil {
|
||||||
|
p.OnToggle(true)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
datepickerInput(datepickerFormatDisplay(p.Value), pick(p.Placeholder, "Select date"), p.Small, extra, p.OnChange),
|
||||||
|
}
|
||||||
|
if p.Clearable && hasValue {
|
||||||
|
fieldMods = append(fieldMods, vdom.El("button",
|
||||||
|
vdom.Attr("type", "button"),
|
||||||
|
vdom.Attr("class", datePickerClearBtn),
|
||||||
|
vdom.Attr("aria-label", "Clear date"),
|
||||||
|
vdom.On(vdom.EVENT_CLICK, func() {
|
||||||
|
if p.OnChange != nil {
|
||||||
|
p.OnChange("")
|
||||||
|
}
|
||||||
|
if p.OnToggle != nil {
|
||||||
|
p.OnToggle(false)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
Icon("xmark", 14, "block leading-none"),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
fieldMods = append(fieldMods, datepickerIconButton(func() {
|
||||||
|
if p.OnToggle != nil {
|
||||||
|
p.OnToggle(!p.Open)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
wrapMods := []vdom.Mod{
|
||||||
|
vdom.Attr("class", datePickerWrap),
|
||||||
|
vdom.El("div", fieldMods...),
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.Open {
|
||||||
|
cal := Calendar(CalendarProps{
|
||||||
|
Selected: p.Value,
|
||||||
|
ViewMonth: p.ViewMonth,
|
||||||
|
Variant: CalendarVariantPicker,
|
||||||
|
OnSelect: func(key string) {
|
||||||
|
if p.OnChange != nil {
|
||||||
|
p.OnChange(key)
|
||||||
|
}
|
||||||
|
if p.OnToggle != nil {
|
||||||
|
p.OnToggle(false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
OnNavigate: p.OnNavigate,
|
||||||
|
})
|
||||||
|
wrapMods = append(wrapMods, vdom.El("div",
|
||||||
|
vdom.Attr("class", cx(datePickerDropdown, datePickerDropdownPos)),
|
||||||
|
cal,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
return vdom.El("div", wrapMods...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// datepickerDOBCalendar renders the date-of-birth calendar: the picker grid with
|
||||||
|
// month/year <select> dropdowns in place of the static month label. Navigation
|
||||||
|
// (prev/next and both selects) is reported through onNavigate as a first-of-month.
|
||||||
|
func datepickerDOBCalendar(selKey string, view time.Time, onSelect func(string), onNavigate func(time.Time)) *vdom.VNode {
|
||||||
|
now := time.Now()
|
||||||
|
sel, hasSel := calParseKey(selKey)
|
||||||
|
|
||||||
|
if view.IsZero() {
|
||||||
|
if hasSel {
|
||||||
|
view = sel
|
||||||
|
} else {
|
||||||
|
view = now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
year := view.Year()
|
||||||
|
month := view.Month()
|
||||||
|
|
||||||
|
navTo := func(m time.Time) {
|
||||||
|
if onNavigate != nil {
|
||||||
|
onNavigate(m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Month select (option values are 0-indexed, matching the TSX).
|
||||||
|
monthMods := []vdom.Mod{
|
||||||
|
vdom.Attr("class", calSelect),
|
||||||
|
vdom.Prop("value", strconv.Itoa(int(month)-1)),
|
||||||
|
vdom.OnEvent(vdom.EVENT_CHANGE, func(e vdom.Event) {
|
||||||
|
if m, err := strconv.Atoi(e.Value()); err == nil {
|
||||||
|
navTo(time.Date(year, time.Month(m+1), 1, 0, 0, 0, 0, time.UTC))
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
for i, mn := range calMonthsShort {
|
||||||
|
monthMods = append(monthMods, vdom.El("option", vdom.Attr("value", strconv.Itoa(i)), vdom.Text(mn)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Year select: current year down to current-119 (120 years), like the TSX.
|
||||||
|
yearMods := []vdom.Mod{
|
||||||
|
vdom.Attr("class", calSelect),
|
||||||
|
vdom.Prop("value", strconv.Itoa(year)),
|
||||||
|
vdom.OnEvent(vdom.EVENT_CHANGE, func(e vdom.Event) {
|
||||||
|
if y, err := strconv.Atoi(e.Value()); err == nil {
|
||||||
|
navTo(time.Date(y, month, 1, 0, 0, 0, 0, time.UTC))
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
for y := now.Year(); y > now.Year()-120; y-- {
|
||||||
|
ys := strconv.Itoa(y)
|
||||||
|
yearMods = append(yearMods, vdom.El("option", vdom.Attr("value", ys), vdom.Text(ys)))
|
||||||
|
}
|
||||||
|
|
||||||
|
header := vdom.El("div", vdom.Attr("class", calHeaderPicker),
|
||||||
|
vdom.El("button",
|
||||||
|
vdom.Attr("type", "button"),
|
||||||
|
vdom.Attr("class", calNavBtn),
|
||||||
|
vdom.On(vdom.EVENT_CLICK, func() {
|
||||||
|
navTo(time.Date(year, month-1, 1, 0, 0, 0, 0, time.UTC))
|
||||||
|
}),
|
||||||
|
Icon("chevron-left", 16, ""),
|
||||||
|
),
|
||||||
|
vdom.El("select", monthMods...),
|
||||||
|
vdom.El("select", yearMods...),
|
||||||
|
vdom.El("button",
|
||||||
|
vdom.Attr("type", "button"),
|
||||||
|
vdom.Attr("class", calNavBtn),
|
||||||
|
vdom.On(vdom.EVENT_CLICK, func() {
|
||||||
|
navTo(time.Date(year, month+1, 1, 0, 0, 0, 0, time.UTC))
|
||||||
|
}),
|
||||||
|
Icon("chevron-right", 16, ""),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return vdom.El("div",
|
||||||
|
vdom.Attr("class", calPickerRoot),
|
||||||
|
header,
|
||||||
|
calWeekdaysRow(calWeekdaysPicker, calWeekdayPicker),
|
||||||
|
calPickerDaysGrid(view, sel, hasSel, now, onSelect, nil),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DateOfBirthPicker is DatePicker with a month/year select calendar, suited to
|
||||||
|
// picking far-past dates. It reuses DatePickerProps (Clearable is unused).
|
||||||
|
func DateOfBirthPicker(p DatePickerProps) *vdom.VNode {
|
||||||
|
fieldMods := []vdom.Mod{
|
||||||
|
vdom.Attr("class", datePickerField),
|
||||||
|
vdom.On(vdom.EVENT_CLICK, func() {
|
||||||
|
if p.OnToggle != nil {
|
||||||
|
p.OnToggle(true)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
datepickerInput(datepickerFormatDisplay(p.Value), pick(p.Placeholder, "Select date of birth"), p.Small, "w-full pr-9", p.OnChange),
|
||||||
|
datepickerIconButton(func() {
|
||||||
|
if p.OnToggle != nil {
|
||||||
|
p.OnToggle(!p.Open)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
wrapMods := []vdom.Mod{
|
||||||
|
vdom.Attr("class", datePickerWrap),
|
||||||
|
vdom.El("div", fieldMods...),
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.Open {
|
||||||
|
cal := datepickerDOBCalendar(p.Value, p.ViewMonth, func(key string) {
|
||||||
|
if p.OnChange != nil {
|
||||||
|
p.OnChange(key)
|
||||||
|
}
|
||||||
|
if p.OnToggle != nil {
|
||||||
|
p.OnToggle(false)
|
||||||
|
}
|
||||||
|
}, p.OnNavigate)
|
||||||
|
wrapMods = append(wrapMods, vdom.El("div",
|
||||||
|
vdom.Attr("class", cx(datePickerDropdown, datePickerDropdownPos)),
|
||||||
|
cal,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
return vdom.El("div", wrapMods...)
|
||||||
|
}
|
||||||
47
go/webui/envbadge.go
Normal file
47
go/webui/envbadge.go
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
// Port of web/kit/EnvBadge.tsx.
|
||||||
|
|
||||||
|
package webui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"kjol/vdom"
|
||||||
|
)
|
||||||
|
|
||||||
|
// IsNonProdEnv reports whether env is a non-empty, non-production deployment
|
||||||
|
// environment (development, staging, …). Mirrors env.ts's isNonProdEnv. In the
|
||||||
|
// TSX the environment is a build-time constant (ENV_TYPE); here it collapses to
|
||||||
|
// a plain argument the caller supplies.
|
||||||
|
func IsNonProdEnv(env string) bool {
|
||||||
|
return env != "" && env != "production"
|
||||||
|
}
|
||||||
|
|
||||||
|
const envBadgeBase = "pointer-events-none select-none absolute top-0 -right-2 z-10 " +
|
||||||
|
"rounded px-1 py-px text-[0.5rem] font-bold uppercase leading-none tracking-wider shadow-sm"
|
||||||
|
|
||||||
|
// EnvBadge renders a small environment badge pinned to the corner of the app
|
||||||
|
// logo. It returns nil in production (and for an empty env), so it renders
|
||||||
|
// nothing there — drop it inside a position:relative wrapper around the logo so
|
||||||
|
// it anchors to the logo's top-right corner.
|
||||||
|
//
|
||||||
|
// NOTE: the TSX also tags <html data-env=…> as a module side effect
|
||||||
|
// (markEnvOnRoot). That is a browser-only document mutation with no
|
||||||
|
// neutral-runtime equivalent and is dropped here; do it in app bootstrap if the
|
||||||
|
// env-specific styling hook is needed.
|
||||||
|
func EnvBadge(env string) *vdom.VNode {
|
||||||
|
if !IsNonProdEnv(env) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var label, tone string
|
||||||
|
switch env {
|
||||||
|
case "development":
|
||||||
|
label, tone = "DEV", "bg-orange-500 text-white"
|
||||||
|
case "staging":
|
||||||
|
label, tone = "STAGING", "bg-yellow-400 text-gray-900"
|
||||||
|
default:
|
||||||
|
label, tone = strings.ToUpper(env), "bg-neutral-700 text-white"
|
||||||
|
}
|
||||||
|
|
||||||
|
return vdom.El("span", vdom.Attr("class", cx(envBadgeBase, tone)), vdom.Text(label))
|
||||||
|
}
|
||||||
189
go/webui/floating.go
Normal file
189
go/webui/floating.go
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
package webui
|
||||||
|
|
||||||
|
import "kjol/vdom"
|
||||||
|
|
||||||
|
// Port of web/kit/Floating.tsx.
|
||||||
|
//
|
||||||
|
// The TSX is a floating-ui-style popover kit built on Solid context, refs,
|
||||||
|
// getBoundingClientRect, requestAnimationFrame, window scroll/resize listeners,
|
||||||
|
// document-level mousedown/keydown handlers, a Portal to document.body, and a
|
||||||
|
// global single-open FloatingManager. None of that has an equivalent in the
|
||||||
|
// neutral vdom runtime, so this port keeps the API shape (Root / Trigger /
|
||||||
|
// Content) plus the Tailwind, and models open state as a plain value + callback.
|
||||||
|
//
|
||||||
|
// NOTE: All computed positioning (calculatePosition, flip, shift, offset px,
|
||||||
|
// getBoundingClientRect, the position() signal, window scroll/resize reflow) is
|
||||||
|
// dropped. FloatingContent is approximated with a statically-positioned
|
||||||
|
// `absolute` element anchored to FloatingRoot's `relative` container, placed via
|
||||||
|
// Tailwind utilities chosen from the Placement value. The original rendered the
|
||||||
|
// content through a Portal with `position: fixed`; here it stays in-flow.
|
||||||
|
// NOTE: The global single-open FloatingManager, the open-order stack
|
||||||
|
// (openFloatings), outside-click dismissal, Escape-to-close, and hover-open /
|
||||||
|
// hover-close timers are dropped — callers own open state and decide when to
|
||||||
|
// toggle it. useFloatingContext / FloatingContextValue and the useFloatingHover
|
||||||
|
// hook are dropped (no Solid context; nothing to share through).
|
||||||
|
|
||||||
|
// Placement values (subset of CSS anchor placements) understood by
|
||||||
|
// FloatingContent's static positioning approximation.
|
||||||
|
const (
|
||||||
|
PlacementTop = "top"
|
||||||
|
PlacementTopStart = "top-start"
|
||||||
|
PlacementTopEnd = "top-end"
|
||||||
|
PlacementBottom = "bottom"
|
||||||
|
PlacementBottomStart = "bottom-start"
|
||||||
|
PlacementBottomEnd = "bottom-end"
|
||||||
|
PlacementLeft = "left"
|
||||||
|
PlacementLeftStart = "left-start"
|
||||||
|
PlacementLeftEnd = "left-end"
|
||||||
|
PlacementRight = "right"
|
||||||
|
PlacementRightStart = "right-start"
|
||||||
|
PlacementRightEnd = "right-end"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PositionOptions mirrors the TSX PositionOptions. Retained for API parity; in
|
||||||
|
// this port only Placement influences rendering (see the file-level NOTE) — the
|
||||||
|
// numeric/flip/shift fields are not consumed because there is no measurement.
|
||||||
|
type PositionOptions struct {
|
||||||
|
Placement string
|
||||||
|
Offset int
|
||||||
|
Flip bool
|
||||||
|
Shift bool
|
||||||
|
ShiftPadding int
|
||||||
|
}
|
||||||
|
|
||||||
|
// floatingPlacementClass maps a Placement to Tailwind utilities that position an
|
||||||
|
// `absolute` child relative to its `relative` FloatingRoot container. The ~4px
|
||||||
|
// default offset is approximated with the mt-1/mb-1/ml-1/mr-1 gap classes.
|
||||||
|
func floatingPlacementClass(placement string) string {
|
||||||
|
switch placement {
|
||||||
|
case PlacementTop:
|
||||||
|
return "bottom-full left-1/2 -translate-x-1/2 mb-1"
|
||||||
|
case PlacementTopStart:
|
||||||
|
return "bottom-full left-0 mb-1"
|
||||||
|
case PlacementTopEnd:
|
||||||
|
return "bottom-full right-0 mb-1"
|
||||||
|
case PlacementBottom:
|
||||||
|
return "top-full left-1/2 -translate-x-1/2 mt-1"
|
||||||
|
case PlacementBottomEnd:
|
||||||
|
return "top-full right-0 mt-1"
|
||||||
|
case PlacementLeft:
|
||||||
|
return "right-full top-1/2 -translate-y-1/2 mr-1"
|
||||||
|
case PlacementLeftStart:
|
||||||
|
return "right-full top-0 mr-1"
|
||||||
|
case PlacementLeftEnd:
|
||||||
|
return "right-full bottom-0 mr-1"
|
||||||
|
case PlacementRight:
|
||||||
|
return "left-full top-1/2 -translate-y-1/2 ml-1"
|
||||||
|
case PlacementRightStart:
|
||||||
|
return "left-full top-0 ml-1"
|
||||||
|
case PlacementRightEnd:
|
||||||
|
return "left-full bottom-0 ml-1"
|
||||||
|
default: // PlacementBottomStart and unknown values
|
||||||
|
return "top-full left-0 mt-1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FloatingRootProps configures FloatingRoot. Open is the current open state
|
||||||
|
// (caller-held); OnOpenChange is invoked by children that toggle it. The
|
||||||
|
// numeric/flip/shift/standalone fields are retained for API parity but are not
|
||||||
|
// used by the static-positioning approximation (see the file-level NOTE).
|
||||||
|
type FloatingRootProps struct {
|
||||||
|
Open bool
|
||||||
|
OnOpenChange func(bool)
|
||||||
|
Placement string
|
||||||
|
Offset int
|
||||||
|
Flip bool
|
||||||
|
Shift bool
|
||||||
|
ShiftPadding int
|
||||||
|
Standalone bool
|
||||||
|
Class string
|
||||||
|
}
|
||||||
|
|
||||||
|
// FloatingRoot wraps a Trigger + Content pair. The TSX component rendered no DOM
|
||||||
|
// node (only a context provider); this port emits a `relative inline-block`
|
||||||
|
// container so the absolutely-positioned FloatingContent has an anchor.
|
||||||
|
func FloatingRoot(p FloatingRootProps, children ...*vdom.VNode) *vdom.VNode {
|
||||||
|
return vdom.El("div", kids([]vdom.Mod{
|
||||||
|
vdom.Attr("class", cx("relative inline-block", p.Class)),
|
||||||
|
}, children)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FloatingTriggerProps configures FloatingTrigger. Open feeds aria-expanded;
|
||||||
|
// OnToggle fires on click. OpenOnHover/HoverDelay/HoverCloseDelay are retained
|
||||||
|
// for API parity but are inert here (hover timers dropped).
|
||||||
|
type FloatingTriggerProps struct {
|
||||||
|
Open bool
|
||||||
|
OnToggle func()
|
||||||
|
OpenOnHover bool
|
||||||
|
HoverDelay int
|
||||||
|
HoverCloseDelay int
|
||||||
|
Class string
|
||||||
|
Title string
|
||||||
|
}
|
||||||
|
|
||||||
|
// FloatingTrigger renders the <button> that toggles the floating content.
|
||||||
|
//
|
||||||
|
// NOTE: keyboard activation (Enter/Space to toggle, Escape to close) and
|
||||||
|
// hover-open/hover-close behavior are dropped — the vdom Event exposes no key,
|
||||||
|
// and there are no timers. Click toggling via OnToggle is preserved.
|
||||||
|
func FloatingTrigger(p FloatingTriggerProps, children ...*vdom.VNode) *vdom.VNode {
|
||||||
|
ariaExpanded := "false"
|
||||||
|
if p.Open {
|
||||||
|
ariaExpanded = "true"
|
||||||
|
}
|
||||||
|
mods := []vdom.Mod{
|
||||||
|
vdom.Attr("type", "button"),
|
||||||
|
vdom.Attr("class", p.Class),
|
||||||
|
vdom.Attr("aria-expanded", ariaExpanded),
|
||||||
|
vdom.Attr("aria-haspopup", "menu"),
|
||||||
|
}
|
||||||
|
if p.Title != "" {
|
||||||
|
mods = append(mods, vdom.Attr("title", p.Title))
|
||||||
|
}
|
||||||
|
if p.OnToggle != nil {
|
||||||
|
mods = append(mods, vdom.On(vdom.EVENT_CLICK, p.OnToggle))
|
||||||
|
}
|
||||||
|
return vdom.El("button", kids(mods, children)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FloatingContentProps configures FloatingContent. Open toggles visibility;
|
||||||
|
// Placement picks the static position (see floatingPlacementClass). Style is an
|
||||||
|
// optional extra inline-style passthrough. OnMouseEnter/OnMouseLeave are wired
|
||||||
|
// (used by hover popovers to keep themselves open), though the close timer they
|
||||||
|
// fed in the TSX is gone.
|
||||||
|
type FloatingContentProps struct {
|
||||||
|
Open bool
|
||||||
|
Placement string
|
||||||
|
Class string
|
||||||
|
Style string
|
||||||
|
OnMouseEnter func()
|
||||||
|
OnMouseLeave func()
|
||||||
|
}
|
||||||
|
|
||||||
|
// FloatingContent renders the popover panel (role="menu"). It is hidden via the
|
||||||
|
// `hidden` utility while closed rather than unmounted.
|
||||||
|
//
|
||||||
|
// NOTE: rendered in-flow as an `absolute` element instead of portaled to
|
||||||
|
// document.body with computed `position: fixed` coordinates; z-[110] preserves
|
||||||
|
// the TSX's stacking intent (above a z-[100] modal container).
|
||||||
|
func FloatingContent(p FloatingContentProps, children ...*vdom.VNode) *vdom.VNode {
|
||||||
|
vis := "hidden"
|
||||||
|
if p.Open {
|
||||||
|
vis = "block"
|
||||||
|
}
|
||||||
|
mods := []vdom.Mod{
|
||||||
|
vdom.Attr("role", "menu"),
|
||||||
|
vdom.Attr("data-floating-content", "true"),
|
||||||
|
vdom.Attr("class", cx("absolute z-[110]", floatingPlacementClass(p.Placement), vis, p.Class)),
|
||||||
|
}
|
||||||
|
if p.Style != "" {
|
||||||
|
mods = append(mods, vdom.Attr("style", p.Style))
|
||||||
|
}
|
||||||
|
if p.OnMouseEnter != nil {
|
||||||
|
mods = append(mods, vdom.On("mouseenter", p.OnMouseEnter))
|
||||||
|
}
|
||||||
|
if p.OnMouseLeave != nil {
|
||||||
|
mods = append(mods, vdom.On("mouseleave", p.OnMouseLeave))
|
||||||
|
}
|
||||||
|
return vdom.El("div", kids(mods, children)...)
|
||||||
|
}
|
||||||
306
go/webui/formatters.go
Normal file
306
go/webui/formatters.go
Normal file
@@ -0,0 +1,306 @@
|
|||||||
|
// Port of web/kit/Formatters.ts.
|
||||||
|
package webui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// formattersNonDigit matches every non-digit rune (the JS /\D/g).
|
||||||
|
var formattersNonDigit = regexp.MustCompile(`\D`)
|
||||||
|
|
||||||
|
// formattersDigits strips every non-digit character (mirrors String(n).replace(/\D/g, "")).
|
||||||
|
func formattersDigits(s string) string {
|
||||||
|
return formattersNonDigit.ReplaceAllString(s, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// formattersStringify mimics JavaScript's String(v) for the `string | number`
|
||||||
|
// union accepted by several formatters. Go has no union type, so these take
|
||||||
|
// `any` and stringify int / float / string inputs the way JS would.
|
||||||
|
func formattersStringify(v any) string {
|
||||||
|
switch n := v.(type) {
|
||||||
|
case string:
|
||||||
|
return n
|
||||||
|
case int:
|
||||||
|
return strconv.Itoa(n)
|
||||||
|
case int8, int16, int32, int64:
|
||||||
|
return fmt.Sprintf("%d", n)
|
||||||
|
case uint, uint8, uint16, uint32, uint64:
|
||||||
|
return fmt.Sprintf("%d", n)
|
||||||
|
case float32:
|
||||||
|
return strconv.FormatFloat(float64(n), 'f', -1, 32)
|
||||||
|
case float64:
|
||||||
|
return strconv.FormatFloat(n, 'f', -1, 64)
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("%v", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// formattersPadStartZero left-pads s with '0' up to length (the JS padStart(n, "0")).
|
||||||
|
func formattersPadStartZero(s string, length int) string {
|
||||||
|
if len(s) >= length {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return strings.Repeat("0", length-len(s)) + s
|
||||||
|
}
|
||||||
|
|
||||||
|
// formattersParseInt approximates JS parseInt(s, 10): optional sign then leading
|
||||||
|
// decimal digits. JS would yield NaN for non-numeric input; here we return 0.
|
||||||
|
func formattersParseInt(s string) int {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
i := 0
|
||||||
|
neg := false
|
||||||
|
if i < len(s) && (s[i] == '+' || s[i] == '-') {
|
||||||
|
neg = s[i] == '-'
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
start := i
|
||||||
|
for i < len(s) && s[i] >= '0' && s[i] <= '9' {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
if start == i {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
n, err := strconv.Atoi(s[start:i])
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if neg {
|
||||||
|
return -n
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatPhoneNumber formats a number as a US phone number: (XXX) XXX-XXXX.
|
||||||
|
func FormatPhoneNumber(number any) string {
|
||||||
|
digits := formattersPadStartZero(formattersDigits(formattersStringify(number)), 10)[:10]
|
||||||
|
areaCode := digits[0:3]
|
||||||
|
centralOfficeCode := digits[3:6]
|
||||||
|
lineNumber := digits[6:10]
|
||||||
|
return "(" + areaCode + ") " + centralOfficeCode + "-" + lineNumber
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatZipCode formats a number as a US zip code (5 or 9 digits).
|
||||||
|
func FormatZipCode(number any) string {
|
||||||
|
num := formattersParseInt(formattersStringify(number))
|
||||||
|
|
||||||
|
if num <= 99999 {
|
||||||
|
return formattersPadStartZero(strconv.Itoa(num), 5)
|
||||||
|
}
|
||||||
|
|
||||||
|
digits := formattersPadStartZero(strconv.Itoa(num), 9)
|
||||||
|
zipCode := digits[0:5]
|
||||||
|
plus4 := digits[5:9]
|
||||||
|
return zipCode + "-" + plus4
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatTaxID formats a number as a US Tax ID (EIN): XX-XXXXXXX.
|
||||||
|
func FormatTaxID(number any) string {
|
||||||
|
digits := formattersPadStartZero(formattersDigits(formattersStringify(number)), 9)[:9]
|
||||||
|
prefix := digits[0:2]
|
||||||
|
identifier := digits[2:9]
|
||||||
|
return prefix + "-" + identifier
|
||||||
|
}
|
||||||
|
|
||||||
|
// formattersGroupThousands inserts ',' every three digits from the right of an
|
||||||
|
// integer-digit string (no sign, no fraction).
|
||||||
|
func formattersGroupThousands(intDigits string) string {
|
||||||
|
n := len(intDigits)
|
||||||
|
if n <= 3 {
|
||||||
|
return intDigits
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
pre := n % 3
|
||||||
|
if pre > 0 {
|
||||||
|
b.WriteString(intDigits[:pre])
|
||||||
|
}
|
||||||
|
for i := pre; i < n; i += 3 {
|
||||||
|
if b.Len() > 0 {
|
||||||
|
b.WriteByte(',')
|
||||||
|
}
|
||||||
|
b.WriteString(intDigits[i : i+3])
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// formattersFormatGrouped renders n like Intl.NumberFormat("en-US") with the
|
||||||
|
// given minimum/maximum fraction digits: rounds to maxFrac, trims trailing zeros
|
||||||
|
// down to minFrac, and groups the integer part with commas.
|
||||||
|
func formattersFormatGrouped(n float64, minFrac, maxFrac int) string {
|
||||||
|
neg := math.Signbit(n)
|
||||||
|
s := strconv.FormatFloat(math.Abs(n), 'f', maxFrac, 64)
|
||||||
|
|
||||||
|
intPart, fracPart := s, ""
|
||||||
|
if dot := strings.IndexByte(s, '.'); dot >= 0 {
|
||||||
|
intPart = s[:dot]
|
||||||
|
fracPart = s[dot+1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
for len(fracPart) > minFrac && strings.HasSuffix(fracPart, "0") {
|
||||||
|
fracPart = fracPart[:len(fracPart)-1]
|
||||||
|
}
|
||||||
|
|
||||||
|
intPart = formattersGroupThousands(intPart)
|
||||||
|
res := intPart
|
||||||
|
if len(fracPart) > 0 {
|
||||||
|
res += "." + fracPart
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only keep the sign when the rounded result is actually non-zero (avoids "-0").
|
||||||
|
if neg && strings.ContainsFunc(res, func(r rune) bool { return r >= '1' && r <= '9' }) {
|
||||||
|
res = "-" + res
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatNumber formats a number with US thousands separators (Intl.NumberFormat
|
||||||
|
// "en-US" defaults: 0 minimum and 3 maximum fraction digits).
|
||||||
|
func FormatNumber(number float64) string {
|
||||||
|
return formattersFormatGrouped(number, 0, 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatDecimal formats a number with US thousands separators and a fixed number
|
||||||
|
// of fraction digits (default 2). Go has no default parameters, so decimalPlaces
|
||||||
|
// is an optional variadic argument.
|
||||||
|
func FormatDecimal(number float64, decimalPlaces ...int) string {
|
||||||
|
dp := 2
|
||||||
|
if len(decimalPlaces) > 0 {
|
||||||
|
dp = decimalPlaces[0]
|
||||||
|
}
|
||||||
|
return formattersFormatGrouped(number, dp, dp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// State Code Utilities
|
||||||
|
|
||||||
|
var formattersStateCodeMap = map[string]string{
|
||||||
|
"Alabama": "AL", "Alaska": "AK", "Arizona": "AZ", "Arkansas": "AR", "California": "CA",
|
||||||
|
"Colorado": "CO", "Connecticut": "CT", "Delaware": "DE", "District of Columbia": "DC", "Florida": "FL",
|
||||||
|
"Georgia": "GA", "Hawaii": "HI", "Idaho": "ID", "Illinois": "IL", "Indiana": "IN",
|
||||||
|
"Iowa": "IA", "Kansas": "KS", "Kentucky": "KY", "Louisiana": "LA", "Maine": "ME",
|
||||||
|
"Maryland": "MD", "Massachusetts": "MA", "Michigan": "MI", "Minnesota": "MN", "Mississippi": "MS",
|
||||||
|
"Missouri": "MO", "Montana": "MT", "Nebraska": "NE", "Nevada": "NV", "New Hampshire": "NH",
|
||||||
|
"New Jersey": "NJ", "New Mexico": "NM", "New York": "NY", "North Carolina": "NC", "North Dakota": "ND",
|
||||||
|
"Ohio": "OH", "Oklahoma": "OK", "Oregon": "OR", "Pennsylvania": "PA", "Puerto Rico": "PR",
|
||||||
|
"Rhode Island": "RI", "South Carolina": "SC", "South Dakota": "SD", "Tennessee": "TN", "Texas": "TX",
|
||||||
|
"Utah": "UT", "Vermont": "VT", "Virgin Islands": "VI", "Virginia": "VA", "Washington": "WA",
|
||||||
|
"West Virginia": "WV", "Wisconsin": "WI", "Wyoming": "WY",
|
||||||
|
}
|
||||||
|
|
||||||
|
// StateToStateCode resolves a state name to its two-letter code, matching the
|
||||||
|
// exact name first and then case-insensitively. Returns "" when unknown.
|
||||||
|
func StateToStateCode(state string) string {
|
||||||
|
if code, ok := formattersStateCodeMap[state]; ok {
|
||||||
|
return code
|
||||||
|
}
|
||||||
|
|
||||||
|
stateLower := strings.ToLower(state)
|
||||||
|
for stateName, code := range formattersStateCodeMap {
|
||||||
|
if strings.ToLower(stateName) == stateLower {
|
||||||
|
return code
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// StateCodeToState resolves a two-letter code to its state name. Returns "" when unknown.
|
||||||
|
func StateCodeToState(code string) string {
|
||||||
|
up := strings.ToUpper(code)
|
||||||
|
for state, stateCode := range formattersStateCodeMap {
|
||||||
|
if stateCode == up {
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsValidStateCode reports whether code is a known two-letter state code.
|
||||||
|
func IsValidStateCode(code string) bool {
|
||||||
|
up := strings.ToUpper(code)
|
||||||
|
for _, stateCode := range formattersStateCodeMap {
|
||||||
|
if stateCode == up {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// formattersDateLayouts are the layouts tried when coercing a string into a
|
||||||
|
// time.Time, standing in for JS `new Date(string)` (which cannot be reproduced
|
||||||
|
// exactly). Ordered most-specific first.
|
||||||
|
var formattersDateLayouts = []string{
|
||||||
|
time.RFC3339Nano,
|
||||||
|
time.RFC3339,
|
||||||
|
"2006-01-02T15:04:05",
|
||||||
|
"2006-01-02T15:04",
|
||||||
|
"2006-01-02 15:04:05",
|
||||||
|
"2006-01-02",
|
||||||
|
"2006/01/02",
|
||||||
|
"01/02/2006",
|
||||||
|
time.RFC1123Z,
|
||||||
|
time.RFC1123,
|
||||||
|
time.ANSIC,
|
||||||
|
}
|
||||||
|
|
||||||
|
// formattersToTime coerces the `string | Date` union into a time.Time. The bool
|
||||||
|
// is false when a string could not be parsed (JS would produce "Invalid Date").
|
||||||
|
func formattersToTime(date any) (time.Time, bool) {
|
||||||
|
switch d := date.(type) {
|
||||||
|
case time.Time:
|
||||||
|
return d, true
|
||||||
|
case string:
|
||||||
|
for _, layout := range formattersDateLayouts {
|
||||||
|
if t, err := time.Parse(layout, d); err == nil {
|
||||||
|
return t, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return time.Time{}, false
|
||||||
|
default:
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatDate formats a date as MM/DD/YYYY. Returns "" for an unparseable string.
|
||||||
|
func FormatDate(date any) string {
|
||||||
|
d, ok := formattersToTime(date)
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return d.Format("01/02/2006")
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatDateLong formats a date as e.g. "January 2, 2006". Returns "" for an
|
||||||
|
// unparseable string.
|
||||||
|
func FormatDateLong(date any) string {
|
||||||
|
d, ok := formattersToTime(date)
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return d.Format("January 2, 2006")
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatDateTime formats a date+time as e.g. "01/02/2006, 3:04 PM". Returns ""
|
||||||
|
// for an unparseable string.
|
||||||
|
func FormatDateTime(date any) string {
|
||||||
|
d, ok := formattersToTime(date)
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return d.Format("01/02/2006, 3:04 PM")
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatPercent formats a value as a percentage with a fixed number of decimal
|
||||||
|
// places. When isDecimal is true the value is treated as a fraction (0-1) and
|
||||||
|
// scaled by 100. Go has no default parameters, so decimalPlaces and isDecimal
|
||||||
|
// (defaults 2 and false in the TS source) are required here.
|
||||||
|
func FormatPercent(value float64, decimalPlaces int, isDecimal bool) string {
|
||||||
|
percent := value
|
||||||
|
if isDecimal {
|
||||||
|
percent = value * 100
|
||||||
|
}
|
||||||
|
return strconv.FormatFloat(percent, 'f', decimalPlaces, 64) + "%"
|
||||||
|
}
|
||||||
1264
go/webui/forms.go
Normal file
1264
go/webui/forms.go
Normal file
File diff suppressed because it is too large
Load Diff
392
go/webui/fuzzymatch.go
Normal file
392
go/webui/fuzzymatch.go
Normal file
@@ -0,0 +1,392 @@
|
|||||||
|
package webui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
|
"kjol/vdom"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Port of web/kit/FuzzyMatch.tsx.
|
||||||
|
//
|
||||||
|
// The fuzzy-scoring/matching logic (Forrest Smith's fts_fuzzy_match, Sublime-
|
||||||
|
// style subsequence scoring) is pure and is ported directly to Go below. The
|
||||||
|
// component keeps the API + structure + Tailwind and models query / open /
|
||||||
|
// highlighted state as plain value props + callbacks.
|
||||||
|
//
|
||||||
|
// NOTE: the exported matcher `fuzzyMatch` would collide (case-folded) with the
|
||||||
|
// `FuzzyMatch` component in Go, so it is renamed to FuzzyMatchOne here. The
|
||||||
|
// other matchers keep their names (Go-cased): FuzzyMatchTypoTolerant,
|
||||||
|
// FuzzySegments, RankFuzzyMatches.
|
||||||
|
// NOTE: positions are rune indices (the TSX used UTF-16 code-unit indices);
|
||||||
|
// these agree for BMP text and are what FuzzySegments consumes here.
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Fuzzy matching (Sublime-style subsequence scoring)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// FuzzyMatchResult is a scored match with the matched character positions.
|
||||||
|
type FuzzyMatchResult struct {
|
||||||
|
Score int
|
||||||
|
Positions []int
|
||||||
|
}
|
||||||
|
|
||||||
|
// FuzzySegment is a run of text flagged matched or unmatched (for highlighting).
|
||||||
|
type FuzzySegment struct {
|
||||||
|
Text string
|
||||||
|
Match bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// FuzzyRankedItem is one ranked option: its value, score, and highlight segments.
|
||||||
|
type FuzzyRankedItem struct {
|
||||||
|
Value string
|
||||||
|
Score int
|
||||||
|
Segments []FuzzySegment
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
fuzzySequentialBonus = 15
|
||||||
|
fuzzySeparatorBonus = 30
|
||||||
|
fuzzyCamelBonus = 30
|
||||||
|
fuzzyFirstLetterBonus = 15
|
||||||
|
fuzzyLeadingPenalty = -5
|
||||||
|
fuzzyMaxLeadingPenalty = -15
|
||||||
|
fuzzyUnmatchedPenalty = -1
|
||||||
|
fuzzyRecursionLimit = 10
|
||||||
|
fuzzyTransposePenalty = -20
|
||||||
|
fuzzyExactSubstrBonus = 100
|
||||||
|
)
|
||||||
|
|
||||||
|
func fuzzyIsLower(c rune) bool { return c >= 'a' && c <= 'z' }
|
||||||
|
func fuzzyIsUpper(c rune) bool { return c >= 'A' && c <= 'Z' }
|
||||||
|
func fuzzyIsSeparator(c rune) bool { return c == ' ' || c == '_' || c == '-' }
|
||||||
|
|
||||||
|
func fuzzyMax(a, b int) int {
|
||||||
|
if a > b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func fuzzyScore(target []rune, matches []int) int {
|
||||||
|
score := 100
|
||||||
|
score += fuzzyMax(fuzzyMaxLeadingPenalty, fuzzyLeadingPenalty*matches[0])
|
||||||
|
score += fuzzyUnmatchedPenalty * (len(target) - len(matches))
|
||||||
|
|
||||||
|
for i := 0; i < len(matches); i++ {
|
||||||
|
curr := matches[i]
|
||||||
|
if i > 0 && curr == matches[i-1]+1 {
|
||||||
|
score += fuzzySequentialBonus
|
||||||
|
}
|
||||||
|
if curr == 0 {
|
||||||
|
score += fuzzyFirstLetterBonus
|
||||||
|
} else {
|
||||||
|
prev := target[curr-1]
|
||||||
|
if fuzzyIsLower(prev) && fuzzyIsUpper(target[curr]) {
|
||||||
|
score += fuzzyCamelBonus
|
||||||
|
}
|
||||||
|
if fuzzyIsSeparator(prev) {
|
||||||
|
score += fuzzySeparatorBonus
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return score
|
||||||
|
}
|
||||||
|
|
||||||
|
func fuzzyRecurse(query, target []rune, qi, ti int, matches []int, rec *int) []int {
|
||||||
|
*rec++
|
||||||
|
if *rec >= fuzzyRecursionLimit {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var best []int
|
||||||
|
for qi < len(query) && ti < len(target) {
|
||||||
|
if unicode.ToLower(query[qi]) == unicode.ToLower(target[ti]) {
|
||||||
|
skipped := fuzzyRecurse(query, target, qi, ti+1, append([]int(nil), matches...), rec)
|
||||||
|
if skipped != nil && (best == nil || fuzzyScore(target, skipped) > fuzzyScore(target, best)) {
|
||||||
|
best = skipped
|
||||||
|
}
|
||||||
|
matches = append(matches, ti)
|
||||||
|
qi++
|
||||||
|
}
|
||||||
|
ti++
|
||||||
|
}
|
||||||
|
|
||||||
|
if qi < len(query) {
|
||||||
|
return best // query not fully consumed -> this path failed
|
||||||
|
}
|
||||||
|
if best == nil || fuzzyScore(target, matches) > fuzzyScore(target, best) {
|
||||||
|
return matches
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
// FuzzyMatchOne scores query against target, or returns nil if the query is
|
||||||
|
// empty or is not a subsequence of target. (Port of the TSX `fuzzyMatch`.)
|
||||||
|
func FuzzyMatchOne(query, target string) *FuzzyMatchResult {
|
||||||
|
if query == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
qr := []rune(query)
|
||||||
|
tr := []rune(target)
|
||||||
|
rec := 0
|
||||||
|
matches := fuzzyRecurse(qr, tr, 0, 0, nil, &rec)
|
||||||
|
if matches == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
score := fuzzyScore(tr, matches)
|
||||||
|
// A contiguous substring hit ("bankof" in "Bankof") should outrank a
|
||||||
|
// word-boundary match split across tokens ("Bank of America"). The bonus is
|
||||||
|
// constant per query/target, so it lives here rather than in the per-
|
||||||
|
// alignment scorer the recursion uses to pick match positions.
|
||||||
|
if strings.Contains(strings.ToLower(target), strings.ToLower(query)) {
|
||||||
|
score += fuzzyExactSubstrBonus
|
||||||
|
}
|
||||||
|
return &FuzzyMatchResult{Score: score, Positions: matches}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FuzzyMatchTypoTolerant also tries every single adjacent-swap variant of the
|
||||||
|
// query (subsequence matching can't tolerate a transposed typo like "teh" vs
|
||||||
|
// "the"), penalizing transposed hits so exact matches still rank first.
|
||||||
|
func FuzzyMatchTypoTolerant(query, target string) *FuzzyMatchResult {
|
||||||
|
best := FuzzyMatchOne(query, target)
|
||||||
|
qr := []rune(query)
|
||||||
|
for i := 0; i < len(qr)-1; i++ {
|
||||||
|
swapped := string(qr[:i]) + string(qr[i+1]) + string(qr[i]) + string(qr[i+2:])
|
||||||
|
m := FuzzyMatchOne(swapped, target)
|
||||||
|
if m == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
score := m.Score + fuzzyTransposePenalty
|
||||||
|
if best == nil || score > best.Score {
|
||||||
|
best = &FuzzyMatchResult{Score: score, Positions: m.Positions}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
// FuzzySegments splits text into alternating matched / unmatched runs for
|
||||||
|
// highlighting, using the given (rune-index) positions.
|
||||||
|
func FuzzySegments(text string, positions []int) []FuzzySegment {
|
||||||
|
matched := make(map[int]bool, len(positions))
|
||||||
|
for _, p := range positions {
|
||||||
|
matched[p] = true
|
||||||
|
}
|
||||||
|
tr := []rune(text)
|
||||||
|
var segments []FuzzySegment
|
||||||
|
buf := ""
|
||||||
|
bufMatch := matched[0]
|
||||||
|
for i := 0; i < len(tr); i++ {
|
||||||
|
isMatch := matched[i]
|
||||||
|
if isMatch != bufMatch {
|
||||||
|
if buf != "" {
|
||||||
|
segments = append(segments, FuzzySegment{Text: buf, Match: bufMatch})
|
||||||
|
}
|
||||||
|
buf = ""
|
||||||
|
bufMatch = isMatch
|
||||||
|
}
|
||||||
|
buf += string(tr[i])
|
||||||
|
}
|
||||||
|
if buf != "" {
|
||||||
|
segments = append(segments, FuzzySegment{Text: buf, Match: bufMatch})
|
||||||
|
}
|
||||||
|
return segments
|
||||||
|
}
|
||||||
|
|
||||||
|
// RankFuzzyMatches ranks options against query, best score first, with
|
||||||
|
// highlight segments. Returns nil for an empty query. maxResults <= 0 means no
|
||||||
|
// limit. This is the headless entry point.
|
||||||
|
func RankFuzzyMatches(query string, options []string, maxResults int) []FuzzyRankedItem {
|
||||||
|
q := strings.TrimSpace(query)
|
||||||
|
if q == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var out []FuzzyRankedItem
|
||||||
|
for _, value := range options {
|
||||||
|
m := FuzzyMatchTypoTolerant(q, value)
|
||||||
|
if m != nil {
|
||||||
|
out = append(out, FuzzyRankedItem{Value: value, Score: m.Score, Segments: FuzzySegments(value, m.Positions)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.SliceStable(out, func(i, j int) bool { return out[i].Score > out[j].Score })
|
||||||
|
if maxResults > 0 && len(out) > maxResults {
|
||||||
|
out = out[:maxResults]
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Component
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// FuzzyMatchDisplay selects how FuzzyMatch renders its results.
|
||||||
|
type FuzzyMatchDisplay = string
|
||||||
|
|
||||||
|
const (
|
||||||
|
FuzzyDisplayList = "list" // inline highlighted results below the input (default)
|
||||||
|
FuzzyDisplayDropdown = "dropdown" // ComboBox-style autocomplete popover
|
||||||
|
FuzzyDisplayNone = "none" // render only the input (headless)
|
||||||
|
)
|
||||||
|
|
||||||
|
const fuzzyInputCls = "bg-white block w-full border border-neutral-300 rounded-default shadow-xs text-sm p-2 h-[38px] focus:outline-2 focus:outline-offset-1 focus:outline-sky-500"
|
||||||
|
|
||||||
|
// Mirror FormCombobox's dropdown styling: neutral hover / highlight.
|
||||||
|
const fuzzyDropdownCls = "bg-white border border-neutral-300 rounded-default shadow-lg max-h-60 overflow-auto"
|
||||||
|
const fuzzyDropdownOptionCls = "w-full text-left p-2 text-sm cursor-pointer flex items-center justify-between gap-2 bg-transparent border-none hover:bg-neutral-100 whitespace-nowrap"
|
||||||
|
const fuzzyDropdownOptionHighlightCls = "bg-neutral-100"
|
||||||
|
|
||||||
|
// FuzzyMatchProps configures the FuzzyMatch component. Query / Open / Highlighted
|
||||||
|
// are caller-held state (read at the call site); the On* callbacks report changes.
|
||||||
|
type FuzzyMatchProps struct {
|
||||||
|
Options []string
|
||||||
|
Display FuzzyMatchDisplay // default FuzzyDisplayList
|
||||||
|
ShowScores bool
|
||||||
|
MaxResults int
|
||||||
|
Placeholder string
|
||||||
|
Class string
|
||||||
|
ListClass string
|
||||||
|
|
||||||
|
Query string // current query text
|
||||||
|
Open bool // dropdown open (Display == dropdown)
|
||||||
|
Highlighted int // highlighted result index (dropdown keyboard selection)
|
||||||
|
|
||||||
|
OnSelect func(value string, item FuzzyRankedItem)
|
||||||
|
OnQueryChange func(query string)
|
||||||
|
OnResults func(results []FuzzyRankedItem) // see NOTE: not auto-invoked
|
||||||
|
}
|
||||||
|
|
||||||
|
// fuzzyHighlight renders a segment list as sky-highlighted / plain spans.
|
||||||
|
func fuzzyHighlight(segments []FuzzySegment) []*vdom.VNode {
|
||||||
|
out := make([]*vdom.VNode, 0, len(segments))
|
||||||
|
for _, seg := range segments {
|
||||||
|
if seg.Match {
|
||||||
|
out = append(out, vdom.El("span", vdom.Attr("class", "text-sky-700 font-semibold"), vdom.Text(seg.Text)))
|
||||||
|
} else {
|
||||||
|
out = append(out, vdom.El("span", vdom.Text(seg.Text)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// fuzzyScoreBadge renders the debug score badge, or nil when disabled.
|
||||||
|
func fuzzyScoreBadge(show bool, score int) *vdom.VNode {
|
||||||
|
if !show {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return vdom.El("span", vdom.Attr("class", "ml-3 shrink-0 text-xs text-neutral-400"), vdom.Text(strconv.Itoa(score)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// fuzzyListView renders the inline "list" display. Before the user types, all
|
||||||
|
// options show unranked so the searchable set is visible up front.
|
||||||
|
func fuzzyListView(p FuzzyMatchProps, results []FuzzyRankedItem) *vdom.VNode {
|
||||||
|
var items []FuzzyRankedItem
|
||||||
|
if strings.TrimSpace(p.Query) != "" {
|
||||||
|
items = results
|
||||||
|
} else {
|
||||||
|
for _, v := range p.Options {
|
||||||
|
items = append(items, FuzzyRankedItem{Value: v, Score: 0, Segments: []FuzzySegment{{Text: v, Match: false}}})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
outer := []vdom.Mod{vdom.Attr("class", cx("mt-3", pick(p.ListClass, "h-72 overflow-y-auto")))}
|
||||||
|
if len(items) > 0 {
|
||||||
|
ul := []vdom.Mod{vdom.Attr("class", "flex flex-col gap-0.5")}
|
||||||
|
for _, r := range items {
|
||||||
|
r := r
|
||||||
|
li := []vdom.Mod{
|
||||||
|
vdom.Attr("class", "flex items-center justify-between gap-3 px-2 py-1 rounded-default cursor-pointer hover:bg-neutral-100"),
|
||||||
|
}
|
||||||
|
if p.OnSelect != nil {
|
||||||
|
li = append(li, vdom.On(vdom.EVENT_CLICK, func() { p.OnSelect(r.Value, r) }))
|
||||||
|
}
|
||||||
|
li = append(li, vdom.El("span", kids([]vdom.Mod{vdom.Attr("class", "text-sm text-neutral-800")}, fuzzyHighlight(r.Segments))...))
|
||||||
|
if badge := fuzzyScoreBadge(p.ShowScores, r.Score); badge != nil {
|
||||||
|
li = append(li, badge)
|
||||||
|
}
|
||||||
|
ul = append(ul, vdom.El("li", li...))
|
||||||
|
}
|
||||||
|
outer = append(outer, vdom.El("ul", ul...))
|
||||||
|
} else if strings.TrimSpace(p.Query) != "" {
|
||||||
|
outer = append(outer, vdom.El("p",
|
||||||
|
vdom.Attr("class", "text-sm text-neutral-500 italic"),
|
||||||
|
vdom.Text(`No matches for "`+p.Query+`".`)))
|
||||||
|
}
|
||||||
|
return vdom.El("div", outer...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// fuzzyDropdownView renders the "dropdown" display's option panel.
|
||||||
|
//
|
||||||
|
// NOTE: the TSX portaled this to document.body with `position: fixed` coords
|
||||||
|
// from getBoundingClientRect + a resize/scroll effect; here it's a statically
|
||||||
|
// `absolute` panel anchored under the input (the container is `relative`).
|
||||||
|
func fuzzyDropdownView(p FuzzyMatchProps, results []FuzzyRankedItem) *vdom.VNode {
|
||||||
|
mods := []vdom.Mod{
|
||||||
|
vdom.Attr("data-floating-content", "true"),
|
||||||
|
vdom.Attr("class", cx("absolute left-0 right-0 top-full mt-1 z-[200]", fuzzyDropdownCls)),
|
||||||
|
}
|
||||||
|
for i, r := range results {
|
||||||
|
r := r
|
||||||
|
cls := fuzzyDropdownOptionCls
|
||||||
|
if i == p.Highlighted {
|
||||||
|
cls = cx(fuzzyDropdownOptionCls, fuzzyDropdownOptionHighlightCls)
|
||||||
|
}
|
||||||
|
btnMods := []vdom.Mod{
|
||||||
|
vdom.Attr("type", "button"),
|
||||||
|
vdom.Attr("class", cls),
|
||||||
|
}
|
||||||
|
if p.OnSelect != nil {
|
||||||
|
btnMods = append(btnMods, vdom.On(vdom.EVENT_CLICK, func() { p.OnSelect(r.Value, r) }))
|
||||||
|
}
|
||||||
|
btnMods = append(btnMods, vdom.El("span", kids([]vdom.Mod{vdom.Attr("class", "text-neutral-800")}, fuzzyHighlight(r.Segments))...))
|
||||||
|
if badge := fuzzyScoreBadge(p.ShowScores, r.Score); badge != nil {
|
||||||
|
btnMods = append(btnMods, badge)
|
||||||
|
}
|
||||||
|
mods = append(mods, vdom.El("button", btnMods...))
|
||||||
|
}
|
||||||
|
return vdom.El("div", mods...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FuzzyMatch renders a fuzzy-search input with inline ("list"), autocomplete
|
||||||
|
// ("dropdown"), or headless ("none") result display.
|
||||||
|
//
|
||||||
|
// NOTE: keyboard navigation (ArrowUp/Down/Enter/Escape) is dropped — the vdom
|
||||||
|
// Event exposes no key. In dropdown mode, updating Query and closing Open on
|
||||||
|
// select, opening on focus/typing, and outside-click dismissal are the caller's
|
||||||
|
// responsibility (report them via the On* callbacks). OnResults is retained for
|
||||||
|
// API parity but is not auto-invoked; headless callers should call
|
||||||
|
// RankFuzzyMatches directly.
|
||||||
|
func FuzzyMatch(p FuzzyMatchProps) *vdom.VNode {
|
||||||
|
display := p.Display
|
||||||
|
if display == "" {
|
||||||
|
display = FuzzyDisplayList
|
||||||
|
}
|
||||||
|
|
||||||
|
results := RankFuzzyMatches(p.Query, p.Options, p.MaxResults)
|
||||||
|
|
||||||
|
inputMods := []vdom.Mod{
|
||||||
|
vdom.Attr("type", "text"),
|
||||||
|
vdom.Attr("class", fuzzyInputCls),
|
||||||
|
vdom.Prop("value", p.Query),
|
||||||
|
vdom.Attr("placeholder", pick(p.Placeholder, "Search...")),
|
||||||
|
}
|
||||||
|
if p.OnQueryChange != nil {
|
||||||
|
inputMods = append(inputMods, vdom.OnEvent(vdom.EVENT_INPUT, func(e vdom.Event) { p.OnQueryChange(e.Value()) }))
|
||||||
|
}
|
||||||
|
|
||||||
|
mods := []vdom.Mod{
|
||||||
|
vdom.Attr("class", cx("relative", p.Class)),
|
||||||
|
vdom.El("input", inputMods...),
|
||||||
|
}
|
||||||
|
|
||||||
|
switch display {
|
||||||
|
case FuzzyDisplayList:
|
||||||
|
mods = append(mods, fuzzyListView(p, results))
|
||||||
|
case FuzzyDisplayDropdown:
|
||||||
|
if p.Open && len(results) > 0 {
|
||||||
|
mods = append(mods, fuzzyDropdownView(p, results))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return vdom.El("div", mods...)
|
||||||
|
}
|
||||||
92
go/webui/general.go
Normal file
92
go/webui/general.go
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
package webui
|
||||||
|
|
||||||
|
import "kjol/vdom"
|
||||||
|
|
||||||
|
// Port of web/kit/General.tsx. Anchor components render plain <a href>; the app
|
||||||
|
// wires SPA navigation on top (e.g. its layout nav uses Button/OnClick).
|
||||||
|
|
||||||
|
// PageContainer wraps a page body (marker class kept for app CSS).
|
||||||
|
func PageContainer(children ...*vdom.VNode) *vdom.VNode {
|
||||||
|
return vdom.El("div", kids([]vdom.Mod{vdom.Attr("class", "admin-page-container")}, children)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Divider is a thin horizontal rule.
|
||||||
|
func Divider() *vdom.VNode { return vdom.El("hr", vdom.Attr("class", "text-neutral-200 mt-1 mb-3")) }
|
||||||
|
|
||||||
|
// CodeBox renders a dark monospace code block.
|
||||||
|
func CodeBox(code, class string) *vdom.VNode {
|
||||||
|
return vdom.El("div",
|
||||||
|
vdom.Attr("class", cx("text-xs p-3 bg-neutral-800 text-neutral-100 border border-neutral-700 rounded-default", class)),
|
||||||
|
vdom.El("pre", vdom.El("code", vdom.Text(code))),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PageHeader is a centered page title with an underline.
|
||||||
|
func PageHeader(text, class string) *vdom.VNode {
|
||||||
|
return vdom.El("header", vdom.Attr("class", class),
|
||||||
|
vdom.El("div", vdom.Attr("class", "mt-1"),
|
||||||
|
vdom.El("h1", vdom.Attr("class", "text-center text-2xl font-light text-neutral-800 mb-2"), vdom.Text(text)),
|
||||||
|
vdom.El("hr", vdom.Attr("class", "text-neutral-200 mb-2")),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PageLink is an inline text link (blue). newTab opens in a new tab.
|
||||||
|
func PageLink(href string, newTab bool, class string, children ...*vdom.VNode) *vdom.VNode {
|
||||||
|
mods := []vdom.Mod{
|
||||||
|
vdom.Attr("href", href),
|
||||||
|
vdom.Attr("class", cx("text-sky-700 hover:text-sky-800 hover:underline hover:decoration-1", class)),
|
||||||
|
}
|
||||||
|
if newTab {
|
||||||
|
mods = append(mods, vdom.Attr("target", "_blank"), vdom.Attr("rel", "noopener noreferrer"))
|
||||||
|
}
|
||||||
|
return vdom.El("a", kids(mods, children)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loader is a centered spinning ring.
|
||||||
|
func Loader() *vdom.VNode {
|
||||||
|
return vdom.El("div", vdom.Attr("class", "flex items-center justify-center p-8"),
|
||||||
|
vdom.El("div", vdom.Attr("class", "h-8 w-8 border-4 border-sky-700 border-t-transparent rounded-full animate-spin")),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BreadcrumbItem is one crumb (URL + display text).
|
||||||
|
type BreadcrumbItem struct {
|
||||||
|
URL string
|
||||||
|
DisplayText string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Breadcrumbs renders a chevron-separated crumb trail; the last item is bold and
|
||||||
|
// not linked.
|
||||||
|
func Breadcrumbs(items []BreadcrumbItem) *vdom.VNode {
|
||||||
|
mods := []vdom.Mod{vdom.Attr("class", "flex flex-row items-center text-neutral-400 text-xs")}
|
||||||
|
for i, crumb := range items {
|
||||||
|
if i != len(items)-1 {
|
||||||
|
mods = append(mods, vdom.El("span", vdom.Attr("class", "flex items-center"),
|
||||||
|
vdom.El("a", vdom.Attr("href", crumb.URL),
|
||||||
|
vdom.Attr("class", "text-neutral-500 cursor-pointer no-underline hover:text-neutral-700 hover:underline"),
|
||||||
|
vdom.Text(crumb.DisplayText)),
|
||||||
|
Icon("chevron-right", 12, "mx-[0.15rem] opacity-50"),
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
mods = append(mods, vdom.El("span", vdom.Attr("class", "text-neutral-700 font-medium"), vdom.Text(crumb.DisplayText)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return vdom.El("div", mods...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ManagerPageHeader is a title/description row with an optional action slot
|
||||||
|
// (marker classes kept for app CSS).
|
||||||
|
func ManagerPageHeader(title, description string, action *vdom.VNode) *vdom.VNode {
|
||||||
|
inner := vdom.El("div",
|
||||||
|
vdom.El("h2", vdom.Attr("class", "page-title"), vdom.Text(title)),
|
||||||
|
)
|
||||||
|
if description != "" {
|
||||||
|
inner.Children = append(inner.Children, vdom.El("p", vdom.Attr("class", "page-desc"), vdom.Text(description)))
|
||||||
|
}
|
||||||
|
mods := []vdom.Mod{vdom.Attr("class", "page-header"), inner}
|
||||||
|
if action != nil {
|
||||||
|
mods = append(mods, action)
|
||||||
|
}
|
||||||
|
return vdom.El("div", mods...)
|
||||||
|
}
|
||||||
94
go/webui/icons.go
Normal file
94
go/webui/icons.go
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
package webui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"kjol/vdom"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Port of web/kit/Icons.tsx. The TSX resolves FontAwesome glyphs from an
|
||||||
|
// app-generated registry (@appgen/faIcons). Here the registry is a package var
|
||||||
|
// seeded with a small common set; apps add their own with RegisterIcon. Each
|
||||||
|
// entry is the inner SVG markup for a 0 0 24 24 viewBox (Heroicons-style), so
|
||||||
|
// the outer <svg> just sizes it.
|
||||||
|
type iconDef struct {
|
||||||
|
viewBox string
|
||||||
|
content string
|
||||||
|
}
|
||||||
|
|
||||||
|
var iconRegistry = map[string]iconDef{}
|
||||||
|
|
||||||
|
// RegisterIcon registers (or overrides) an icon. viewBox is like "0 0 24 24";
|
||||||
|
// content is the inner SVG markup (e.g. one or more <path> elements).
|
||||||
|
func RegisterIcon(name, viewBox, content string) {
|
||||||
|
iconRegistry[name] = iconDef{viewBox: viewBox, content: content}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Icon renders a named icon at the given pixel size (0 => 16). Extra classes are
|
||||||
|
// appended. Unknown names render an empty, correctly-sized box (never blanks the
|
||||||
|
// layout). Mirrors Icons.tsx's <svg fill=currentColor width height>innerHTML</svg>.
|
||||||
|
func Icon(name string, size int, class string) *vdom.VNode {
|
||||||
|
if size <= 0 {
|
||||||
|
size = 16
|
||||||
|
}
|
||||||
|
def, ok := iconRegistry[name]
|
||||||
|
vb := "0 0 24 24"
|
||||||
|
content := ""
|
||||||
|
if ok {
|
||||||
|
vb = def.viewBox
|
||||||
|
content = def.content
|
||||||
|
}
|
||||||
|
s := strconv.Itoa(size)
|
||||||
|
return vdom.El("svg",
|
||||||
|
vdom.Attr("xmlns", "http://www.w3.org/2000/svg"),
|
||||||
|
vdom.Attr("viewBox", vb),
|
||||||
|
vdom.Attr("fill", "currentColor"),
|
||||||
|
vdom.Attr("width", s), vdom.Attr("height", s),
|
||||||
|
vdom.Attr("aria-hidden", "true"),
|
||||||
|
vdom.Attr("class", cx("shrink-0", class)),
|
||||||
|
vdom.Raw(content),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IconInline is Icon with inline-block alignment (for use within text runs).
|
||||||
|
func IconInline(name string, size int, class string) *vdom.VNode {
|
||||||
|
return Icon(name, size, cx("inline-block align-middle", class))
|
||||||
|
}
|
||||||
|
|
||||||
|
// IconSuccess / IconError are green/red inline convenience wrappers.
|
||||||
|
func IconSuccess(name string, size int, class string) *vdom.VNode {
|
||||||
|
return Icon(name, size, cx("inline-block align-middle text-green-600", class))
|
||||||
|
}
|
||||||
|
func IconError(name string, size int, class string) *vdom.VNode {
|
||||||
|
return Icon(name, size, cx("inline-block align-middle text-red-600", class))
|
||||||
|
}
|
||||||
|
|
||||||
|
// IconContainer is a flex row that vertically centers an icon + text.
|
||||||
|
func IconContainer(children ...*vdom.VNode) *vdom.VNode {
|
||||||
|
return vdom.El("span", kids([]vdom.Mod{vdom.Attr("class", "flex flex-row items-center gap-2")}, children)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A small default set of stroke-based icons (Heroicons outline, 24x24) so common
|
||||||
|
// components render out of the box. Apps register more via RegisterIcon.
|
||||||
|
func strokePath(d string) string {
|
||||||
|
return `<path stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none" d="` + d + `"/>`
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
reg := func(name, d string) { RegisterIcon(name, "0 0 24 24", strokePath(d)) }
|
||||||
|
reg("chevron-left", "M15.75 19.5 8.25 12l7.5-7.5")
|
||||||
|
reg("chevron-right", "m8.25 4.5 7.5 7.5-7.5 7.5")
|
||||||
|
reg("chevron-down", "m19.5 8.25-7.5 7.5-7.5-7.5")
|
||||||
|
reg("chevron-up", "m4.5 15.75 7.5-7.5 7.5 7.5")
|
||||||
|
reg("check", "m4.5 12.75 6 6 9-13.5")
|
||||||
|
reg("xmark", "M6 18 18 6M6 6l12 12")
|
||||||
|
reg("x", "M6 18 18 6M6 6l12 12")
|
||||||
|
reg("plus", "M12 4.5v15m7.5-7.5h-15")
|
||||||
|
reg("minus", "M4.5 12h15")
|
||||||
|
reg("bars", "M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5")
|
||||||
|
reg("arrow-right", "M13.5 4.5 21 12m0 0-7.5 7.5M21 12H3")
|
||||||
|
reg("external-link", "M13.5 6H5.25A2.25 2.25 0 0 0 3 8.25v10.5A2.25 2.25 0 0 0 5.25 21h10.5A2.25 2.25 0 0 0 18 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25")
|
||||||
|
reg("info", "M11.25 11.25h1.5v4.5M12 8.25h.008M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z")
|
||||||
|
reg("triangle-exclamation", "M12 9v3.75m0 3.75h.008M10.34 3.94 1.7 18a1.5 1.5 0 0 0 1.3 2.25h18a1.5 1.5 0 0 0 1.3-2.25L13.66 3.94a1.5 1.5 0 0 0-2.6 0Z")
|
||||||
|
reg("circle-check", "M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z")
|
||||||
|
}
|
||||||
221
go/webui/menu.go
Normal file
221
go/webui/menu.go
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
package webui
|
||||||
|
|
||||||
|
import "kjol/vdom"
|
||||||
|
|
||||||
|
// Port of web/kit/Menu.tsx.
|
||||||
|
//
|
||||||
|
// NOTE: floating-ui positioning (FloatingRoot / FloatingTrigger / FloatingContent
|
||||||
|
// / useFloatingContext / useFloatingHover) is dropped. Menu is a `relative`
|
||||||
|
// wrapper and MenuContent is an `absolute` dropdown positioned with static
|
||||||
|
// Tailwind utilities chosen from MenuPlacement; there is no viewport-aware
|
||||||
|
// collision detection.
|
||||||
|
// NOTE: Solid's MenuContext (closeMenu / openOnHover / cancelParentClose) is
|
||||||
|
// dropped. Open state is a passed bool: MenuContent and Submenu render only when
|
||||||
|
// open, and the caller toggles it via MenuTrigger's onToggle. Item clicks no
|
||||||
|
// longer auto-close the menu (closeOnClick / closeMenu removed) — the caller
|
||||||
|
// closes it from its own click handler.
|
||||||
|
// NOTE: hover-open/hover-close timers, the MenuTrigger render-prop (isOpen state)
|
||||||
|
// and asChild, Submenu's getBoundingClientRect positioning with scroll/resize
|
||||||
|
// listeners, and MenuItem's Enter/Space keydown handler (the runtime's Event
|
||||||
|
// exposes no key) are all dropped.
|
||||||
|
// NOTE: the imported "Placement" type is renamed MenuPlacement to avoid colliding
|
||||||
|
// with a future Floating port.
|
||||||
|
|
||||||
|
const menuCls = "bg-white rounded-default shadow-lg border border-neutral-200 p-1.5 min-w-48 max-h-96 overflow-y-auto"
|
||||||
|
const menuItemCls = "flex items-center gap-2 w-full py-1.5 px-2 rounded-default text-sm text-left text-neutral-700 bg-transparent border-0 cursor-pointer hover:bg-neutral-100 hover:text-neutral-900 focus:bg-neutral-100 focus:outline-hidden disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
const menuDividerCls = "my-1 -mx-1.5 border-0 border-t border-neutral-200"
|
||||||
|
const menuSectionCls = "pt-1.5 pb-0.5 px-2 text-[10px] font-semibold text-neutral-400 uppercase tracking-wide text-left"
|
||||||
|
const menuSubmenuTriggerCls = "flex items-center justify-between gap-2 w-full py-1.5 px-2 rounded-default text-sm text-left text-neutral-700 cursor-pointer hover:bg-neutral-100 hover:text-neutral-900"
|
||||||
|
|
||||||
|
// MenuPlacement selects where MenuContent is positioned relative to its trigger.
|
||||||
|
type MenuPlacement string
|
||||||
|
|
||||||
|
const (
|
||||||
|
MenuPlacementBottomStart MenuPlacement = "bottom-start"
|
||||||
|
MenuPlacementBottomEnd MenuPlacement = "bottom-end"
|
||||||
|
MenuPlacementTopStart MenuPlacement = "top-start"
|
||||||
|
MenuPlacementTopEnd MenuPlacement = "top-end"
|
||||||
|
MenuPlacementLeftStart MenuPlacement = "left-start"
|
||||||
|
MenuPlacementRightStart MenuPlacement = "right-start"
|
||||||
|
)
|
||||||
|
|
||||||
|
// menuPlacementClasses maps a placement to the static absolute-position utilities
|
||||||
|
// (the ~4px offset becomes the mt-1/mb-1/ml-1/mr-1 margin).
|
||||||
|
var menuPlacementClasses = map[MenuPlacement]string{
|
||||||
|
MenuPlacementBottomStart: "top-full left-0 mt-1",
|
||||||
|
MenuPlacementBottomEnd: "top-full right-0 mt-1",
|
||||||
|
MenuPlacementTopStart: "bottom-full left-0 mb-1",
|
||||||
|
MenuPlacementTopEnd: "bottom-full right-0 mb-1",
|
||||||
|
MenuPlacementLeftStart: "right-full top-0 mr-1",
|
||||||
|
MenuPlacementRightStart: "left-full top-0 ml-1",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Menu is the positioning context: a relative wrapper holding a MenuTrigger and a
|
||||||
|
// MenuContent.
|
||||||
|
func Menu(class string, children ...*vdom.VNode) *vdom.VNode {
|
||||||
|
return vdom.El("div", kids([]vdom.Mod{vdom.Attr("class", cx("relative inline-block", class))}, children)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MenuTrigger wraps the clickable element that toggles the menu open/closed.
|
||||||
|
func MenuTrigger(onToggle func(), class string, children ...*vdom.VNode) *vdom.VNode {
|
||||||
|
mods := []vdom.Mod{
|
||||||
|
vdom.Attr("class", class),
|
||||||
|
vdom.Attr("aria-haspopup", "menu"),
|
||||||
|
}
|
||||||
|
if onToggle != nil {
|
||||||
|
mods = append(mods, vdom.On(vdom.EVENT_CLICK, onToggle))
|
||||||
|
}
|
||||||
|
return vdom.El("div", kids(mods, children)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MenuContent is the dropdown panel; it renders only when open, positioned with
|
||||||
|
// static Tailwind utilities for the given placement.
|
||||||
|
func MenuContent(open bool, placement MenuPlacement, class string, children ...*vdom.VNode) *vdom.VNode {
|
||||||
|
if !open {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
pos := menuPlacementClasses[placement]
|
||||||
|
if pos == "" {
|
||||||
|
pos = menuPlacementClasses[MenuPlacementBottomStart]
|
||||||
|
}
|
||||||
|
mods := []vdom.Mod{
|
||||||
|
vdom.Attr("class", cx("absolute z-50", pos, menuCls, class)),
|
||||||
|
vdom.Attr("role", "menu"),
|
||||||
|
}
|
||||||
|
return vdom.El("div", kids(mods, children)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MenuItemProps configures MenuItem.
|
||||||
|
type MenuItemProps struct {
|
||||||
|
Icon string
|
||||||
|
Disabled bool
|
||||||
|
OnClick func()
|
||||||
|
Class string
|
||||||
|
}
|
||||||
|
|
||||||
|
// MenuItem is a <button> menu entry.
|
||||||
|
func MenuItem(p MenuItemProps, children ...*vdom.VNode) *vdom.VNode {
|
||||||
|
mods := []vdom.Mod{
|
||||||
|
vdom.Attr("type", "button"),
|
||||||
|
vdom.Attr("role", "menuitem"),
|
||||||
|
vdom.Attr("class", cx(menuItemCls, p.Class)),
|
||||||
|
}
|
||||||
|
if p.Disabled {
|
||||||
|
mods = append(mods, vdom.Attr("disabled", "disabled"))
|
||||||
|
} else if p.OnClick != nil {
|
||||||
|
mods = append(mods, vdom.On(vdom.EVENT_CLICK, p.OnClick))
|
||||||
|
}
|
||||||
|
if p.Icon != "" {
|
||||||
|
mods = append(mods, Icon(p.Icon, 16, "shrink-0"))
|
||||||
|
}
|
||||||
|
return vdom.El("button", kids(mods, children)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MenuLink is an anchor menu entry for internal navigation.
|
||||||
|
func MenuLink(href, icon, class string, children ...*vdom.VNode) *vdom.VNode {
|
||||||
|
mods := []vdom.Mod{
|
||||||
|
vdom.Attr("href", href),
|
||||||
|
vdom.Attr("role", "menuitem"),
|
||||||
|
vdom.Attr("class", cx(menuItemCls, class)),
|
||||||
|
}
|
||||||
|
if icon != "" {
|
||||||
|
mods = append(mods, Icon(icon, 16, "shrink-0"))
|
||||||
|
}
|
||||||
|
return vdom.El("a", kids(mods, children)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MenuAnchorProps configures MenuAnchor.
|
||||||
|
type MenuAnchorProps struct {
|
||||||
|
Href string
|
||||||
|
Icon string
|
||||||
|
Target string
|
||||||
|
Rel string
|
||||||
|
Class string
|
||||||
|
}
|
||||||
|
|
||||||
|
// MenuAnchor is an anchor menu entry to an external URL (Target defaults to
|
||||||
|
// _blank, Rel to noopener noreferrer). A _blank target appends a trailing arrow.
|
||||||
|
func MenuAnchor(p MenuAnchorProps, children ...*vdom.VNode) *vdom.VNode {
|
||||||
|
target := pick(p.Target, "_blank")
|
||||||
|
rel := pick(p.Rel, "noopener noreferrer")
|
||||||
|
mods := []vdom.Mod{
|
||||||
|
vdom.Attr("href", p.Href),
|
||||||
|
vdom.Attr("target", target),
|
||||||
|
vdom.Attr("rel", rel),
|
||||||
|
vdom.Attr("role", "menuitem"),
|
||||||
|
vdom.Attr("class", cx(menuItemCls, p.Class)),
|
||||||
|
}
|
||||||
|
if p.Icon != "" {
|
||||||
|
mods = append(mods, Icon(p.Icon, 16, "shrink-0"))
|
||||||
|
}
|
||||||
|
mods = kids(mods, children)
|
||||||
|
if target == "_blank" {
|
||||||
|
mods = append(mods, Icon("arrow-right", 12, "shrink-0 ml-auto text-neutral-400"))
|
||||||
|
}
|
||||||
|
return vdom.El("a", mods...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MenuDivider is a horizontal separator between groups of items.
|
||||||
|
func MenuDivider(class string) *vdom.VNode {
|
||||||
|
return vdom.El("hr", vdom.Attr("class", cx(menuDividerCls, class)), vdom.Attr("role", "separator"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MenuSection is an uppercase section label.
|
||||||
|
func MenuSection(class string, children ...*vdom.VNode) *vdom.VNode {
|
||||||
|
mods := []vdom.Mod{vdom.Attr("class", cx(menuSectionCls, class)), vdom.Attr("role", "presentation")}
|
||||||
|
return vdom.El("div", kids(mods, children)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MenuGroup groups related items together (role=group).
|
||||||
|
func MenuGroup(class string, children ...*vdom.VNode) *vdom.VNode {
|
||||||
|
mods := []vdom.Mod{vdom.Attr("role", "group"), vdom.Attr("class", class)}
|
||||||
|
return vdom.El("div", kids(mods, children)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubmenuProps configures Submenu.
|
||||||
|
type SubmenuProps struct {
|
||||||
|
Open bool
|
||||||
|
OnToggle func()
|
||||||
|
Trigger string
|
||||||
|
Icon string
|
||||||
|
Class string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Submenu is a nested menu opened from a parent item. When Open, its panel
|
||||||
|
// renders to the right of the trigger via static Tailwind (no measurement).
|
||||||
|
func Submenu(p SubmenuProps, children ...*vdom.VNode) *vdom.VNode {
|
||||||
|
ariaExpanded := "false"
|
||||||
|
if p.Open {
|
||||||
|
ariaExpanded = "true"
|
||||||
|
}
|
||||||
|
|
||||||
|
label := []vdom.Mod{vdom.Attr("class", "flex items-center gap-2")}
|
||||||
|
if p.Icon != "" {
|
||||||
|
label = append(label, Icon(p.Icon, 16, "shrink-0"))
|
||||||
|
}
|
||||||
|
label = append(label, vdom.Text(p.Trigger))
|
||||||
|
|
||||||
|
triggerMods := []vdom.Mod{
|
||||||
|
vdom.Attr("role", "menuitem"),
|
||||||
|
vdom.Attr("aria-haspopup", "menu"),
|
||||||
|
vdom.Attr("aria-expanded", ariaExpanded),
|
||||||
|
vdom.Attr("class", cx(menuSubmenuTriggerCls, p.Class)),
|
||||||
|
}
|
||||||
|
if p.OnToggle != nil {
|
||||||
|
triggerMods = append(triggerMods, vdom.On(vdom.EVENT_CLICK, p.OnToggle))
|
||||||
|
}
|
||||||
|
triggerMods = append(triggerMods,
|
||||||
|
vdom.El("span", label...),
|
||||||
|
Icon("chevron-right", 16, "shrink-0 ml-auto text-neutral-400"),
|
||||||
|
)
|
||||||
|
|
||||||
|
wrap := []vdom.Mod{vdom.Attr("class", "relative"), vdom.El("div", triggerMods...)}
|
||||||
|
if p.Open {
|
||||||
|
contentMods := []vdom.Mod{
|
||||||
|
vdom.Attr("role", "menu"),
|
||||||
|
vdom.Attr("class", cx("absolute left-full top-0 ml-1 z-[51]", menuCls)),
|
||||||
|
}
|
||||||
|
wrap = append(wrap, vdom.El("div", kids(contentMods, children)...))
|
||||||
|
}
|
||||||
|
return vdom.El("div", wrap...)
|
||||||
|
}
|
||||||
432
go/webui/modal.go
Normal file
432
go/webui/modal.go
Normal file
@@ -0,0 +1,432 @@
|
|||||||
|
package webui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"kjol/vdom"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Port of web/kit/Modal.tsx.
|
||||||
|
//
|
||||||
|
// NOTE: Solid's Portal (render to document.body) is dropped — modals render
|
||||||
|
// inline where they are placed. Callers should mount them near the page root so
|
||||||
|
// the fixed-position container is not clipped by an ancestor's overflow/transform.
|
||||||
|
// NOTE: the entrance/exit animations (the isVisible signal plus the inline
|
||||||
|
// opacity/scale transition styles) are dropped; the modal renders in its final
|
||||||
|
// visible state.
|
||||||
|
// NOTE: the imperative context (ModalProvider / useModal / openModal(content))
|
||||||
|
// and the Escape-key handling (useModalEscape + the shared open-modal stack,
|
||||||
|
// which needs a document keydown listener) are dropped — there is no context or
|
||||||
|
// document access here. Use Modal with an IsOpen bool + OnClose callback instead.
|
||||||
|
// NOTE: WizardStepContext (setCanContinue/nextStep/prevStep passed into each
|
||||||
|
// step) is dropped. A WizardStep now carries a static Content node; the caller
|
||||||
|
// drives CurrentStep/CanContinue via props. The openVersion reset-on-open memo
|
||||||
|
// is likewise unnecessary here.
|
||||||
|
// NOTE: the undefined-vs-null header/footer distinction collapses to nil — a nil
|
||||||
|
// Header renders the close-only header (the undefined default); there is no
|
||||||
|
// "explicitly no header" case. The "circle-exclamation" wizard-error icon is not
|
||||||
|
// in the default registry and renders as an empty box until an app registers it.
|
||||||
|
|
||||||
|
// ModalSize selects the modal panel's max width.
|
||||||
|
type ModalSize string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ModalSmall ModalSize = "small"
|
||||||
|
ModalDefault ModalSize = "default"
|
||||||
|
ModalMedium ModalSize = "medium"
|
||||||
|
ModalLarge ModalSize = "large"
|
||||||
|
ModalXLarge ModalSize = "xlarge"
|
||||||
|
Modal2XLarge ModalSize = "2xlarge"
|
||||||
|
Modal3XLarge ModalSize = "3xlarge"
|
||||||
|
Modal4XLarge ModalSize = "4xlarge"
|
||||||
|
Modal5XLarge ModalSize = "5xlarge"
|
||||||
|
ModalFull ModalSize = "full"
|
||||||
|
)
|
||||||
|
|
||||||
|
// -- Tailwind class constants --------------------------------------
|
||||||
|
const modalContainerBase = "fixed inset-0 z-[100] w-full h-dvh m-0 border-0 bg-transparent flex justify-center max-w-screen max-h-dvh"
|
||||||
|
const modalContainerTop = "items-start pt-10"
|
||||||
|
const modalContainerCenter = "items-center"
|
||||||
|
|
||||||
|
const modalBackdrop = "fixed inset-0 bg-black/30"
|
||||||
|
|
||||||
|
const modalBase = "relative bg-white shadow-md text-sm w-full rounded-default max-h-[calc(100dvh_-_5rem)] flex flex-col overflow-hidden"
|
||||||
|
|
||||||
|
var modalSizes = map[ModalSize]string{
|
||||||
|
ModalSmall: "max-w-md",
|
||||||
|
ModalDefault: "max-w-xl",
|
||||||
|
ModalMedium: "max-w-2xl",
|
||||||
|
ModalLarge: "max-w-3xl",
|
||||||
|
ModalXLarge: "max-w-4xl",
|
||||||
|
Modal2XLarge: "max-w-5xl",
|
||||||
|
Modal3XLarge: "max-w-6xl",
|
||||||
|
Modal4XLarge: "max-w-7xl",
|
||||||
|
Modal5XLarge: "max-w-[90rem]",
|
||||||
|
ModalFull: "max-w-none",
|
||||||
|
}
|
||||||
|
|
||||||
|
const modalHeader = "flex items-center justify-between py-5 px-7 pb-4 border-b border-neutral-200 text-lg font-semibold text-text-heading"
|
||||||
|
const modalHeaderCloseOnly = "flex items-center justify-end p-4 pb-1"
|
||||||
|
const modalCloseBtn = "cursor-pointer text-neutral-500 bg-transparent border-0 p-0 leading-none hover:text-neutral-700"
|
||||||
|
const modalBody = "px-7 py-6 overflow-y-auto flex-1 min-h-0 [background:linear-gradient(var(--color-white),var(--color-white))_bottom_/_100%_3rem_no-repeat_local,linear-gradient(to_bottom,transparent,var(--color-white))_bottom_/_100%_3rem_no-repeat_scroll,var(--color-white)]"
|
||||||
|
const modalFooter = "py-4 px-7 pb-[calc(1rem_+_env(safe-area-inset-bottom,0px))] flex flex-row justify-end gap-2 border-t border-neutral-200 bg-neutral-50 rounded-b-default"
|
||||||
|
const modalFooterSpacer = "h-2"
|
||||||
|
|
||||||
|
const modalWizardError = "flex items-center gap-2 py-3 px-8 text-sm text-red-700 bg-red-50 border-t border-red-200"
|
||||||
|
const modalWizardErrorIcon = "shrink-0 text-red-500"
|
||||||
|
|
||||||
|
// Confirm modal
|
||||||
|
const modalConfirmWrap = "flex justify-end gap-2"
|
||||||
|
const modalConfirmCancel = "py-2 px-4 text-sm border border-neutral-300 rounded-default bg-transparent cursor-pointer hover:bg-neutral-50"
|
||||||
|
const modalConfirmOkBase = "py-2 px-4 text-sm rounded-default border-0 cursor-pointer text-white"
|
||||||
|
|
||||||
|
var modalConfirmOkVariants = map[string]string{
|
||||||
|
"danger": "bg-red-600 hover:bg-red-700",
|
||||||
|
"primary": "bg-primary hover:bg-primary-hover",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wizard header
|
||||||
|
const modalWizardHeader = "flex flex-col items-center gap-2 flex-1"
|
||||||
|
const modalWizardTitleRow = "flex items-center justify-between w-full"
|
||||||
|
const modalWizardTitle = "text-xl"
|
||||||
|
const modalWizardStepName = "text-xs font-semibold text-neutral-600 uppercase tracking-wider"
|
||||||
|
const modalWizardSteps = "flex items-center justify-between relative w-full max-w-64"
|
||||||
|
const modalWizardTrack = "absolute top-1/2 left-0 right-0 h-0.5 bg-neutral-200 -translate-y-1/2"
|
||||||
|
const modalWizardTrackFill = "h-full bg-primary transition-[width] duration-300 ease-in-out"
|
||||||
|
const modalWizardStepWrap = "relative z-[1]"
|
||||||
|
const modalStepIndicatorBase = "w-7 h-7 rounded-full flex items-center justify-center text-xs font-semibold border-2 shrink-0 transition-all duration-300 ease-in-out"
|
||||||
|
const modalStepIndicatorPending = "border-neutral-300 text-neutral-400 bg-white"
|
||||||
|
const modalStepIndicatorActive = "bg-primary text-white border-primary"
|
||||||
|
const modalStepIndicatorCompleted = "bg-primary text-white border-primary"
|
||||||
|
|
||||||
|
// Wizard footer
|
||||||
|
const modalWizardFooter = "flex items-center justify-between w-full gap-2"
|
||||||
|
const modalWizardBtnBase = "py-2 px-5 text-sm rounded-default cursor-pointer border-0 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
const modalWizardBtnBack = "bg-transparent border border-neutral-300 text-neutral-700 enabled:hover:bg-neutral-50"
|
||||||
|
const modalWizardBtnNext = "bg-neutral-800 text-white enabled:hover:bg-neutral-900"
|
||||||
|
const modalWizardBtnFinish = "bg-primary text-white enabled:hover:bg-red-700"
|
||||||
|
|
||||||
|
// modalCloseButton is the shared "✕" button (dismisses via onClose).
|
||||||
|
func modalCloseButton(onClose func(), size int) *vdom.VNode {
|
||||||
|
mods := []vdom.Mod{vdom.Attr("class", modalCloseBtn)}
|
||||||
|
if onClose != nil {
|
||||||
|
mods = append(mods, vdom.On(vdom.EVENT_CLICK, onClose))
|
||||||
|
}
|
||||||
|
mods = append(mods, Icon("xmark", size, ""))
|
||||||
|
return vdom.El("button", mods...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func modalCloseOnlyHeader(onClose func()) *vdom.VNode {
|
||||||
|
return vdom.El("div", vdom.Attr("class", modalHeaderCloseOnly), modalCloseButton(onClose, 24))
|
||||||
|
}
|
||||||
|
|
||||||
|
func modalFullHeader(header *vdom.VNode, onClose func()) *vdom.VNode {
|
||||||
|
return vdom.El("div", vdom.Attr("class", modalHeader), header, modalCloseButton(onClose, 24))
|
||||||
|
}
|
||||||
|
|
||||||
|
// modalDisplay is the dialog + backdrop + panel wrapper. Clicking the backdrop
|
||||||
|
// invokes onClose.
|
||||||
|
func modalDisplay(size ModalSize, centerOnScreen bool, onClose func(), children ...*vdom.VNode) *vdom.VNode {
|
||||||
|
align := modalContainerTop
|
||||||
|
if centerOnScreen {
|
||||||
|
align = modalContainerCenter
|
||||||
|
}
|
||||||
|
if size == "" {
|
||||||
|
size = ModalDefault
|
||||||
|
}
|
||||||
|
|
||||||
|
backdrop := []vdom.Mod{vdom.Attr("class", modalBackdrop)}
|
||||||
|
if onClose != nil {
|
||||||
|
backdrop = append(backdrop, vdom.On(vdom.EVENT_CLICK, onClose))
|
||||||
|
}
|
||||||
|
|
||||||
|
panel := kids([]vdom.Mod{vdom.Attr("class", cx(modalBase, modalSizes[size]))}, children)
|
||||||
|
|
||||||
|
return vdom.El("dialog",
|
||||||
|
vdom.Attr("open", "open"),
|
||||||
|
vdom.Attr("class", cx(modalContainerBase, align)),
|
||||||
|
vdom.El("div", backdrop...),
|
||||||
|
vdom.El("div", panel...),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// modalContentNodes builds the header / body / footer panel children shared by
|
||||||
|
// Modal and ModalContent. A nil header renders the close-only header; a nil
|
||||||
|
// footer renders the spacer.
|
||||||
|
func modalContentNodes(header, footer *vdom.VNode, onClose func(), children []*vdom.VNode) []*vdom.VNode {
|
||||||
|
var nodes []*vdom.VNode
|
||||||
|
if header == nil {
|
||||||
|
nodes = append(nodes, modalCloseOnlyHeader(onClose))
|
||||||
|
} else {
|
||||||
|
nodes = append(nodes, modalFullHeader(header, onClose))
|
||||||
|
}
|
||||||
|
nodes = append(nodes, vdom.El("div", kids([]vdom.Mod{vdom.Attr("class", modalBody)}, children)...))
|
||||||
|
if footer == nil {
|
||||||
|
nodes = append(nodes, vdom.El("div", vdom.Attr("class", modalFooterSpacer)))
|
||||||
|
} else {
|
||||||
|
nodes = append(nodes, vdom.El("div", vdom.Attr("class", modalFooter), footer))
|
||||||
|
}
|
||||||
|
return nodes
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModalProps configures Modal. A nil Header renders a close-only header; a nil
|
||||||
|
// Footer renders a spacer.
|
||||||
|
type ModalProps struct {
|
||||||
|
IsOpen bool
|
||||||
|
OnClose func()
|
||||||
|
Size ModalSize
|
||||||
|
CenterOnScreen bool
|
||||||
|
Header *vdom.VNode
|
||||||
|
Footer *vdom.VNode
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modal renders a dialog when IsOpen; otherwise it renders nothing (nil).
|
||||||
|
func Modal(p ModalProps, children ...*vdom.VNode) *vdom.VNode {
|
||||||
|
if !p.IsOpen {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
nodes := modalContentNodes(p.Header, p.Footer, p.OnClose, children)
|
||||||
|
return modalDisplay(p.Size, p.CenterOnScreen, p.OnClose, nodes...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModalContentProps configures ModalContent.
|
||||||
|
type ModalContentProps struct {
|
||||||
|
Header *vdom.VNode
|
||||||
|
Footer *vdom.VNode
|
||||||
|
OnClose func()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModalContent renders the header / body / footer trio to drop inside a
|
||||||
|
// modalDisplay panel. The TSX returned a fragment; here it is wrapped in a
|
||||||
|
// display:contents div so it adds no layout box.
|
||||||
|
func ModalContent(p ModalContentProps, children ...*vdom.VNode) *vdom.VNode {
|
||||||
|
nodes := modalContentNodes(p.Header, p.Footer, p.OnClose, children)
|
||||||
|
mods := kids([]vdom.Mod{vdom.Attr("class", "contents")}, nodes)
|
||||||
|
return vdom.El("div", mods...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConfirmModalProps configures ConfirmModal. ConfirmStyle is "danger" (default)
|
||||||
|
// or "primary".
|
||||||
|
type ConfirmModalProps struct {
|
||||||
|
IsOpen bool
|
||||||
|
OnClose func()
|
||||||
|
OnConfirm func()
|
||||||
|
Title string
|
||||||
|
Message string
|
||||||
|
ConfirmText string
|
||||||
|
CancelText string
|
||||||
|
ConfirmStyle string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConfirmModal is a small centered modal with cancel/confirm buttons.
|
||||||
|
func ConfirmModal(p ConfirmModalProps) *vdom.VNode {
|
||||||
|
if !p.IsOpen {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
title := pick(p.Title, "Confirm")
|
||||||
|
confirmText := pick(p.ConfirmText, "Confirm")
|
||||||
|
cancelText := pick(p.CancelText, "Cancel")
|
||||||
|
style := pick(p.ConfirmStyle, "danger")
|
||||||
|
okVariant := modalConfirmOkVariants[style]
|
||||||
|
if okVariant == "" {
|
||||||
|
okVariant = modalConfirmOkVariants["danger"]
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelMods := []vdom.Mod{vdom.Attr("class", modalConfirmCancel)}
|
||||||
|
if p.OnClose != nil {
|
||||||
|
cancelMods = append(cancelMods, vdom.On(vdom.EVENT_CLICK, p.OnClose))
|
||||||
|
}
|
||||||
|
cancelMods = append(cancelMods, vdom.Text(cancelText))
|
||||||
|
|
||||||
|
okMods := []vdom.Mod{
|
||||||
|
vdom.Attr("class", cx(modalConfirmOkBase, okVariant)),
|
||||||
|
vdom.On(vdom.EVENT_CLICK, func() {
|
||||||
|
if p.OnConfirm != nil {
|
||||||
|
p.OnConfirm()
|
||||||
|
}
|
||||||
|
if p.OnClose != nil {
|
||||||
|
p.OnClose()
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
vdom.Text(confirmText),
|
||||||
|
}
|
||||||
|
|
||||||
|
footer := vdom.El("div", vdom.Attr("class", modalConfirmWrap),
|
||||||
|
vdom.El("button", cancelMods...),
|
||||||
|
vdom.El("button", okMods...),
|
||||||
|
)
|
||||||
|
|
||||||
|
return Modal(ModalProps{
|
||||||
|
IsOpen: true,
|
||||||
|
OnClose: p.OnClose,
|
||||||
|
Size: ModalSmall,
|
||||||
|
CenterOnScreen: true,
|
||||||
|
Header: vdom.Text(title),
|
||||||
|
Footer: footer,
|
||||||
|
}, vdom.Text(p.Message))
|
||||||
|
}
|
||||||
|
|
||||||
|
// WizardStep is one step of a WizardModal. Content is rendered statically (the
|
||||||
|
// TSX's per-step callback context is dropped — see the file NOTE).
|
||||||
|
type WizardStep struct {
|
||||||
|
Title string
|
||||||
|
Content *vdom.VNode
|
||||||
|
}
|
||||||
|
|
||||||
|
// WizardModalProps configures WizardModal. CurrentStep + OnStepChange drive
|
||||||
|
// navigation; CanContinue gates the Next/Finish button for the current step.
|
||||||
|
type WizardModalProps struct {
|
||||||
|
IsOpen bool
|
||||||
|
OnClose func()
|
||||||
|
OnComplete func()
|
||||||
|
Steps []WizardStep
|
||||||
|
CurrentStep int
|
||||||
|
OnStepChange func(int)
|
||||||
|
CanContinue bool
|
||||||
|
Size ModalSize
|
||||||
|
CenterOnScreen bool
|
||||||
|
Title string
|
||||||
|
FinishText string
|
||||||
|
Error string
|
||||||
|
}
|
||||||
|
|
||||||
|
func modalStepIndicatorClass(i, cur int) string {
|
||||||
|
switch {
|
||||||
|
case i == cur:
|
||||||
|
return cx(modalStepIndicatorBase, modalStepIndicatorActive)
|
||||||
|
case i < cur:
|
||||||
|
return cx(modalStepIndicatorBase, modalStepIndicatorCompleted)
|
||||||
|
default:
|
||||||
|
return cx(modalStepIndicatorBase, modalStepIndicatorPending)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func modalWizardNextBtnClass(isLast bool) string {
|
||||||
|
if isLast {
|
||||||
|
return cx(modalWizardBtnBase, modalWizardBtnFinish)
|
||||||
|
}
|
||||||
|
return cx(modalWizardBtnBase, modalWizardBtnNext)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WizardModal is a multi-step modal with a progress header and Back/Next footer.
|
||||||
|
func WizardModal(p WizardModalProps) *vdom.VNode {
|
||||||
|
if !p.IsOpen {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
steps := p.Steps
|
||||||
|
total := len(steps)
|
||||||
|
cur := p.CurrentStep
|
||||||
|
size := p.Size
|
||||||
|
if size == "" {
|
||||||
|
size = ModalLarge
|
||||||
|
}
|
||||||
|
finishText := pick(p.FinishText, "Finish")
|
||||||
|
isFirst := cur == 0
|
||||||
|
isLast := cur == total-1
|
||||||
|
|
||||||
|
title := p.Title
|
||||||
|
currentStepTitle := ""
|
||||||
|
if cur >= 0 && cur < total {
|
||||||
|
currentStepTitle = steps[cur].Title
|
||||||
|
if title == "" {
|
||||||
|
title = steps[cur].Title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pct := 100.0
|
||||||
|
if total > 1 {
|
||||||
|
pct = float64(cur) / float64(total-1) * 100
|
||||||
|
}
|
||||||
|
pctStr := strconv.FormatFloat(pct, 'f', -1, 64)
|
||||||
|
|
||||||
|
// Progress track + step indicators.
|
||||||
|
stepsRow := []vdom.Mod{
|
||||||
|
vdom.Attr("class", modalWizardSteps),
|
||||||
|
vdom.El("div", vdom.Attr("class", modalWizardTrack),
|
||||||
|
vdom.El("div", vdom.Attr("class", modalWizardTrackFill), vdom.Attr("style", "width:"+pctStr+"%")),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
for i := range steps {
|
||||||
|
label := strconv.Itoa(i + 1)
|
||||||
|
if i < cur {
|
||||||
|
label = "✓"
|
||||||
|
}
|
||||||
|
stepsRow = append(stepsRow, vdom.El("div", vdom.Attr("class", modalWizardStepWrap),
|
||||||
|
vdom.El("div", vdom.Attr("class", modalStepIndicatorClass(i, cur)), vdom.Text(label)),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
titleRow := vdom.El("div", vdom.Attr("class", modalWizardTitleRow),
|
||||||
|
vdom.El("span", vdom.Attr("class", modalWizardTitle), vdom.Text(title)),
|
||||||
|
modalCloseButton(p.OnClose, 24),
|
||||||
|
)
|
||||||
|
wizardHeader := vdom.El("div", vdom.Attr("class", modalWizardHeader),
|
||||||
|
titleRow,
|
||||||
|
vdom.El("div", vdom.Attr("class", modalWizardStepName), vdom.Text(currentStepTitle)),
|
||||||
|
vdom.El("div", stepsRow...),
|
||||||
|
)
|
||||||
|
headerDiv := vdom.El("div", vdom.Attr("class", modalHeader), wizardHeader)
|
||||||
|
|
||||||
|
// Body: render every step, hiding the non-current ones.
|
||||||
|
bodyMods := []vdom.Mod{vdom.Attr("class", modalBody)}
|
||||||
|
for i, s := range steps {
|
||||||
|
itemMods := []vdom.Mod{}
|
||||||
|
if i != cur {
|
||||||
|
itemMods = append(itemMods, vdom.Attr("style", "display:none"))
|
||||||
|
}
|
||||||
|
if s.Content != nil {
|
||||||
|
itemMods = append(itemMods, s.Content)
|
||||||
|
}
|
||||||
|
bodyMods = append(bodyMods, vdom.El("div", itemMods...))
|
||||||
|
}
|
||||||
|
|
||||||
|
panel := []*vdom.VNode{headerDiv, vdom.El("div", bodyMods...)}
|
||||||
|
|
||||||
|
if p.Error != "" {
|
||||||
|
panel = append(panel, vdom.El("div", vdom.Attr("class", modalWizardError),
|
||||||
|
Icon("circle-exclamation", 16, modalWizardErrorIcon),
|
||||||
|
vdom.El("span", vdom.Text(p.Error)),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Footer: Back / Next(Finish).
|
||||||
|
backMods := []vdom.Mod{vdom.Attr("class", cx(modalWizardBtnBase, modalWizardBtnBack))}
|
||||||
|
if isFirst {
|
||||||
|
backMods = append(backMods, vdom.Attr("disabled", "disabled"))
|
||||||
|
}
|
||||||
|
if p.OnStepChange != nil {
|
||||||
|
backMods = append(backMods, vdom.On(vdom.EVENT_CLICK, func() {
|
||||||
|
if !isFirst {
|
||||||
|
p.OnStepChange(cur - 1)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
backMods = append(backMods, vdom.Text("Back"))
|
||||||
|
|
||||||
|
nextMods := []vdom.Mod{vdom.Attr("class", modalWizardNextBtnClass(isLast))}
|
||||||
|
if !p.CanContinue {
|
||||||
|
nextMods = append(nextMods, vdom.Attr("disabled", "disabled"))
|
||||||
|
}
|
||||||
|
nextMods = append(nextMods, vdom.On(vdom.EVENT_CLICK, func() {
|
||||||
|
if isLast {
|
||||||
|
if p.OnComplete != nil {
|
||||||
|
p.OnComplete()
|
||||||
|
}
|
||||||
|
} else if p.OnStepChange != nil {
|
||||||
|
p.OnStepChange(cur + 1)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
nextLabel := "Next"
|
||||||
|
if isLast {
|
||||||
|
nextLabel = finishText
|
||||||
|
}
|
||||||
|
nextMods = append(nextMods, vdom.Text(nextLabel))
|
||||||
|
|
||||||
|
footerInner := vdom.El("div", vdom.Attr("class", modalWizardFooter),
|
||||||
|
vdom.El("button", backMods...),
|
||||||
|
vdom.El("button", nextMods...),
|
||||||
|
)
|
||||||
|
panel = append(panel, vdom.El("div", vdom.Attr("class", modalFooter), footerInner))
|
||||||
|
|
||||||
|
return modalDisplay(size, p.CenterOnScreen, p.OnClose, panel...)
|
||||||
|
}
|
||||||
165
go/webui/popovers.go
Normal file
165
go/webui/popovers.go
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
// Port of web/kit/Popovers.tsx.
|
||||||
|
//
|
||||||
|
// NOTE: the TSX builds these on Floating.tsx — a floating-ui-style layer with
|
||||||
|
// getBoundingClientRect measurement, requestAnimationFrame reposition, a Portal
|
||||||
|
// to document.body, a global single-open manager, outside-click/Escape handling,
|
||||||
|
// and hover open/close timers, all threaded through a FloatingContext. None of
|
||||||
|
// that has an equivalent in the neutral runtime (no document, portals, refs,
|
||||||
|
// element measurement, or timers), so it is dropped. This port keeps the
|
||||||
|
// component API + Tailwind + event wiring, drives click open/close with a
|
||||||
|
// caller-supplied `open` bool + toggle callback, does hover reveal purely in CSS
|
||||||
|
// (group-hover), and approximates placement with static absolute utility classes
|
||||||
|
// instead of computed coordinates (so flip/shift and a numeric offset are gone —
|
||||||
|
// the gap is a fixed ~8px via the m*-2 classes, matching the TSX default offset).
|
||||||
|
|
||||||
|
package webui
|
||||||
|
|
||||||
|
import "kjol/vdom"
|
||||||
|
|
||||||
|
const popoverCls = "bg-white rounded-default shadow-lg border border-neutral-200"
|
||||||
|
|
||||||
|
// popoverPlacementCls maps a floating placement to static absolute-position
|
||||||
|
// utility classes relative to the Popover's `relative` wrapper.
|
||||||
|
func popoverPlacementCls(placement string) string {
|
||||||
|
switch placement {
|
||||||
|
case "top", "top-start":
|
||||||
|
return "bottom-full left-0 mb-2"
|
||||||
|
case "top-end":
|
||||||
|
return "bottom-full right-0 mb-2"
|
||||||
|
case "bottom-end":
|
||||||
|
return "top-full right-0 mt-2"
|
||||||
|
case "left", "left-start":
|
||||||
|
return "right-full top-0 mr-2"
|
||||||
|
case "left-end":
|
||||||
|
return "right-full bottom-0 mr-2"
|
||||||
|
case "right", "right-start":
|
||||||
|
return "left-full top-0 ml-2"
|
||||||
|
case "right-end":
|
||||||
|
return "left-full bottom-0 ml-2"
|
||||||
|
default: // "bottom" / "bottom-start" and unknown
|
||||||
|
return "top-full left-0 mt-2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PopoverProps configures Popover. Placement defaults to "bottom-start".
|
||||||
|
type PopoverProps struct {
|
||||||
|
Placement string
|
||||||
|
Class string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Popover is the floating wrapper: a relative container that anchors an
|
||||||
|
// absolutely-positioned PopoverContent to a PopoverTrigger. Compose a
|
||||||
|
// PopoverTrigger and a PopoverContent as its children.
|
||||||
|
func Popover(p PopoverProps, children ...*vdom.VNode) *vdom.VNode {
|
||||||
|
return vdom.El("div", kids([]vdom.Mod{vdom.Attr("class", cx("relative inline-block", p.Class))}, children)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PopoverTriggerProps configures PopoverTrigger. Open drives aria-expanded and
|
||||||
|
// OnToggle fires on click (the TSX toggles open on click).
|
||||||
|
type PopoverTriggerProps struct {
|
||||||
|
Open bool
|
||||||
|
OnToggle func()
|
||||||
|
Class string
|
||||||
|
Title string
|
||||||
|
}
|
||||||
|
|
||||||
|
// PopoverTrigger is the click target that opens/closes the popover.
|
||||||
|
func PopoverTrigger(p PopoverTriggerProps, children ...*vdom.VNode) *vdom.VNode {
|
||||||
|
expanded := "false"
|
||||||
|
if p.Open {
|
||||||
|
expanded = "true"
|
||||||
|
}
|
||||||
|
mods := []vdom.Mod{
|
||||||
|
vdom.Attr("type", "button"),
|
||||||
|
vdom.Attr("class", p.Class),
|
||||||
|
vdom.Attr("aria-expanded", expanded),
|
||||||
|
vdom.Attr("aria-haspopup", "menu"),
|
||||||
|
}
|
||||||
|
if p.Title != "" {
|
||||||
|
mods = append(mods, vdom.Attr("title", p.Title))
|
||||||
|
}
|
||||||
|
if p.OnToggle != nil {
|
||||||
|
mods = append(mods, vdom.On(vdom.EVENT_CLICK, p.OnToggle))
|
||||||
|
}
|
||||||
|
return vdom.El("button", kids(mods, children)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PopoverContentProps configures PopoverContent. Placement defaults to
|
||||||
|
// "bottom-start"; Open toggles visibility (nil when closed).
|
||||||
|
type PopoverContentProps struct {
|
||||||
|
Open bool
|
||||||
|
Placement string
|
||||||
|
Class string
|
||||||
|
}
|
||||||
|
|
||||||
|
// PopoverContent is the floating panel. It renders nil when Open is false.
|
||||||
|
func PopoverContent(p PopoverContentProps, children ...*vdom.VNode) *vdom.VNode {
|
||||||
|
if !p.Open {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
mods := []vdom.Mod{
|
||||||
|
vdom.Attr("role", "menu"),
|
||||||
|
vdom.Attr("data-floating-content", "true"),
|
||||||
|
vdom.Attr("class", cx("absolute z-[110]", popoverPlacementCls(p.Placement), popoverCls, p.Class)),
|
||||||
|
}
|
||||||
|
return vdom.El("div", kids(mods, children)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HoverPopoverProps configures HoverPopover. Placement defaults to
|
||||||
|
// "bottom-start".
|
||||||
|
type HoverPopoverProps struct {
|
||||||
|
Placement string
|
||||||
|
Class string
|
||||||
|
}
|
||||||
|
|
||||||
|
// HoverPopover is the hover-driven wrapper. It carries the Tailwind `group`
|
||||||
|
// marker so HoverPopoverContent can reveal itself on hover purely in CSS.
|
||||||
|
func HoverPopover(p HoverPopoverProps, children ...*vdom.VNode) *vdom.VNode {
|
||||||
|
return vdom.El("div", kids([]vdom.Mod{vdom.Attr("class", cx("group relative inline-block", p.Class))}, children)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HoverPopoverTriggerProps configures HoverPopoverTrigger. OnMouseEnter /
|
||||||
|
// OnMouseLeave mirror the TSX hover wiring (optional; hover reveal itself is CSS).
|
||||||
|
type HoverPopoverTriggerProps struct {
|
||||||
|
OnMouseEnter func()
|
||||||
|
OnMouseLeave func()
|
||||||
|
Class string
|
||||||
|
}
|
||||||
|
|
||||||
|
// HoverPopoverTrigger is the hover target.
|
||||||
|
func HoverPopoverTrigger(p HoverPopoverTriggerProps, children ...*vdom.VNode) *vdom.VNode {
|
||||||
|
mods := []vdom.Mod{vdom.Attr("type", "button"), vdom.Attr("class", p.Class)}
|
||||||
|
if p.OnMouseEnter != nil {
|
||||||
|
mods = append(mods, vdom.On("mouseenter", p.OnMouseEnter))
|
||||||
|
}
|
||||||
|
if p.OnMouseLeave != nil {
|
||||||
|
mods = append(mods, vdom.On("mouseleave", p.OnMouseLeave))
|
||||||
|
}
|
||||||
|
return vdom.El("button", kids(mods, children)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HoverPopoverContentProps configures HoverPopoverContent.
|
||||||
|
type HoverPopoverContentProps struct {
|
||||||
|
Placement string
|
||||||
|
Class string
|
||||||
|
OnMouseEnter func()
|
||||||
|
OnMouseLeave func()
|
||||||
|
}
|
||||||
|
|
||||||
|
// HoverPopoverContent is the hover panel: hidden by default and revealed while
|
||||||
|
// the surrounding HoverPopover (group) is hovered.
|
||||||
|
func HoverPopoverContent(p HoverPopoverContentProps, children ...*vdom.VNode) *vdom.VNode {
|
||||||
|
const reveal = "invisible opacity-0 transition-opacity group-hover:visible group-hover:opacity-100"
|
||||||
|
mods := []vdom.Mod{
|
||||||
|
vdom.Attr("role", "menu"),
|
||||||
|
vdom.Attr("data-floating-content", "true"),
|
||||||
|
vdom.Attr("class", cx("absolute z-[110]", popoverPlacementCls(p.Placement), popoverCls, reveal, p.Class)),
|
||||||
|
}
|
||||||
|
if p.OnMouseEnter != nil {
|
||||||
|
mods = append(mods, vdom.On("mouseenter", p.OnMouseEnter))
|
||||||
|
}
|
||||||
|
if p.OnMouseLeave != nil {
|
||||||
|
mods = append(mods, vdom.On("mouseleave", p.OnMouseLeave))
|
||||||
|
}
|
||||||
|
return vdom.El("div", kids(mods, children)...)
|
||||||
|
}
|
||||||
194
go/webui/prettytable.go
Normal file
194
go/webui/prettytable.go
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
package webui
|
||||||
|
|
||||||
|
import "kjol/vdom"
|
||||||
|
|
||||||
|
// Port of web/kit/PrettyTable.tsx.
|
||||||
|
//
|
||||||
|
// PrettyTable renders only the table chrome: the container/wrapper, the styled
|
||||||
|
// <thead>, and an empty-styled <tbody> into which the CALLER passes body rows as
|
||||||
|
// children. That matches the TSX, whose body is `props.children` (rows built with
|
||||||
|
// AutoTable's TdLeft/TdRight/TdCenter cells). Those cell helpers live in AutoTable
|
||||||
|
// (not ported here), so callers build rows with the vdom tag builders directly,
|
||||||
|
// e.g. vdom.El("tr", vdom.El("td", vdom.Attr("class","text-right"), vdom.Text(...))).
|
||||||
|
// (CellGrid is the component that models columns + a per-cell Render func.)
|
||||||
|
//
|
||||||
|
// The enum values, class maps, and shared class strings that PrettyTable.tsx
|
||||||
|
// imports from AutoTable.tsx are inlined here (prefixed pt*/PrettyTable*) so this
|
||||||
|
// file compiles on its own and never clashes with a parallel AutoTable port.
|
||||||
|
|
||||||
|
// PrettyTableColumnPosition controls header + cell alignment.
|
||||||
|
type PrettyTableColumnPosition int
|
||||||
|
|
||||||
|
const (
|
||||||
|
PrettyTableColLeft PrettyTableColumnPosition = 0
|
||||||
|
PrettyTableColRight PrettyTableColumnPosition = 1
|
||||||
|
PrettyTableColCenter PrettyTableColumnPosition = 2
|
||||||
|
)
|
||||||
|
|
||||||
|
// PrettyTableHeaderColor selects the header background/text palette.
|
||||||
|
type PrettyTableHeaderColor int
|
||||||
|
|
||||||
|
const (
|
||||||
|
PrettyTableColorDefault PrettyTableHeaderColor = 0
|
||||||
|
PrettyTableColorBlue PrettyTableHeaderColor = 1
|
||||||
|
PrettyTableColorGreen PrettyTableHeaderColor = 2
|
||||||
|
PrettyTableColorGray PrettyTableHeaderColor = 3
|
||||||
|
PrettyTableColorDarkBlue PrettyTableHeaderColor = 4
|
||||||
|
)
|
||||||
|
|
||||||
|
// PrettyTableSize selects header/body padding density.
|
||||||
|
type PrettyTableSize int
|
||||||
|
|
||||||
|
const (
|
||||||
|
PrettyTableSizeDefault PrettyTableSize = 0
|
||||||
|
PrettyTableSizeCompact PrettyTableSize = 1
|
||||||
|
PrettyTableSizeSuperCompact PrettyTableSize = 2
|
||||||
|
)
|
||||||
|
|
||||||
|
const ptTblContainer = "relative flex flex-col w-full h-full bg-white rounded-default overflow-hidden"
|
||||||
|
const ptTblWrapper = "overflow-x-auto w-full"
|
||||||
|
const ptTblBase = "min-w-full"
|
||||||
|
const ptHeaderContent = "transition-transform duration-150 ease-in-out"
|
||||||
|
const ptHeaderInnerBase = "flex justify-between gap-2 items-center"
|
||||||
|
|
||||||
|
var ptHeaderColorCls = map[PrettyTableHeaderColor]string{
|
||||||
|
PrettyTableColorDefault: "bg-neutral-50",
|
||||||
|
PrettyTableColorBlue: "bg-sky-700 text-white",
|
||||||
|
PrettyTableColorGreen: "bg-green-700 text-white",
|
||||||
|
PrettyTableColorGray: "bg-neutral-600 text-white",
|
||||||
|
PrettyTableColorDarkBlue: "bg-sky-900 text-white",
|
||||||
|
}
|
||||||
|
|
||||||
|
var ptHeaderTextCls = map[PrettyTableHeaderColor]string{
|
||||||
|
PrettyTableColorDefault: "font-bold uppercase tracking-wider",
|
||||||
|
PrettyTableColorBlue: "font-semibold",
|
||||||
|
PrettyTableColorGreen: "font-semibold",
|
||||||
|
PrettyTableColorGray: "font-semibold",
|
||||||
|
PrettyTableColorDarkBlue: "font-semibold",
|
||||||
|
}
|
||||||
|
|
||||||
|
// ptRowHoverCls is applied to the tbody when Hover is on (matches the TSX
|
||||||
|
// PT_ROW_HOVER_CLS map).
|
||||||
|
var ptRowHoverCls = map[PrettyTableHeaderColor]string{
|
||||||
|
PrettyTableColorDefault: "[&_tr:hover]:!bg-neutral-200",
|
||||||
|
PrettyTableColorBlue: "[&_tr:hover]:!bg-sky-100",
|
||||||
|
PrettyTableColorGreen: "[&_tr:hover]:!bg-green-100",
|
||||||
|
PrettyTableColorGray: "[&_tr:hover]:!bg-neutral-200",
|
||||||
|
PrettyTableColorDarkBlue: "[&_tr:hover]:!bg-sky-100",
|
||||||
|
}
|
||||||
|
|
||||||
|
var ptHeaderPaddingCls = map[PrettyTableSize]string{
|
||||||
|
PrettyTableSizeDefault: "p-4 text-sm",
|
||||||
|
PrettyTableSizeCompact: "py-1.5 px-2 text-sm",
|
||||||
|
PrettyTableSizeSuperCompact: "py-1 px-2 text-xs",
|
||||||
|
}
|
||||||
|
|
||||||
|
var ptBodyPaddingCls = map[PrettyTableSize]string{
|
||||||
|
PrettyTableSizeDefault: "text-sm [&_td]:p-4",
|
||||||
|
PrettyTableSizeCompact: "text-sm [&_td]:py-1 [&_td]:px-2",
|
||||||
|
PrettyTableSizeSuperCompact: "text-xs [&_td]:py-0.5 [&_td]:px-2",
|
||||||
|
}
|
||||||
|
|
||||||
|
var ptPosCls = map[PrettyTableColumnPosition]string{
|
||||||
|
PrettyTableColLeft: "text-left",
|
||||||
|
PrettyTableColRight: "text-right",
|
||||||
|
PrettyTableColCenter: "text-center",
|
||||||
|
}
|
||||||
|
|
||||||
|
var ptHeaderInnerPos = map[PrettyTableColumnPosition]string{
|
||||||
|
PrettyTableColLeft: "",
|
||||||
|
PrettyTableColRight: "flex-row-reverse",
|
||||||
|
PrettyTableColCenter: "justify-center",
|
||||||
|
}
|
||||||
|
|
||||||
|
// PrettyTableColumn is one header column definition.
|
||||||
|
type PrettyTableColumn struct {
|
||||||
|
DisplayName string
|
||||||
|
DisplayPosition PrettyTableColumnPosition
|
||||||
|
HeaderClasses string
|
||||||
|
}
|
||||||
|
|
||||||
|
// PrettyTableOptions configures the table chrome. The Go zero value matches the
|
||||||
|
// TSX defaults exactly (size default, all flags off, default color, left align),
|
||||||
|
// so an unset PrettyTableOptions{} needs no merge.
|
||||||
|
type PrettyTableOptions struct {
|
||||||
|
Size PrettyTableSize
|
||||||
|
Shadow bool
|
||||||
|
Hover bool
|
||||||
|
Alternate bool
|
||||||
|
HeaderBorderY bool
|
||||||
|
SurroundingBorder bool
|
||||||
|
BorderX bool
|
||||||
|
BorderY bool
|
||||||
|
Color PrettyTableHeaderColor
|
||||||
|
TableLayoutAuto bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func prettyTableBodyClass(o PrettyTableOptions) string {
|
||||||
|
c := ptBodyPaddingCls[o.Size]
|
||||||
|
if o.BorderY {
|
||||||
|
c = cx(c, "[&_td+td]:border-l [&_td+td]:border-neutral-300")
|
||||||
|
}
|
||||||
|
if o.Alternate {
|
||||||
|
c = cx(c, "[&_tr:nth-child(even)]:bg-neutral-100")
|
||||||
|
}
|
||||||
|
if o.BorderX {
|
||||||
|
c = cx(c, "[&_tr:not(:last-child)]:border-b [&_tr:not(:last-child)]:border-neutral-300")
|
||||||
|
}
|
||||||
|
if o.Hover {
|
||||||
|
c = cx(c, ptRowHoverCls[o.Color])
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// PrettyTable renders the table container + styled header, with body rows passed
|
||||||
|
// as children (see the file comment). opts may be the zero value for defaults.
|
||||||
|
func PrettyTable(columns []PrettyTableColumn, opts PrettyTableOptions, children ...*vdom.VNode) *vdom.VNode {
|
||||||
|
containerCls := ptTblContainer
|
||||||
|
if opts.SurroundingBorder {
|
||||||
|
containerCls = cx(containerCls, "border border-neutral-300")
|
||||||
|
}
|
||||||
|
if opts.Shadow {
|
||||||
|
containerCls = cx(containerCls, "shadow-sm")
|
||||||
|
}
|
||||||
|
|
||||||
|
tableCls := ptTblBase
|
||||||
|
if !opts.TableLayoutAuto {
|
||||||
|
tableCls = cx(tableCls, "table-fixed")
|
||||||
|
}
|
||||||
|
|
||||||
|
var headerCells []*vdom.VNode
|
||||||
|
for displayIdx, col := range columns {
|
||||||
|
pos := col.DisplayPosition
|
||||||
|
thCls := cx(ptHeaderPaddingCls[opts.Size], ptHeaderColorCls[opts.Color], ptPosCls[pos])
|
||||||
|
if opts.HeaderBorderY && displayIdx > 0 {
|
||||||
|
thCls = cx(thCls, "border-l border-l-neutral-300")
|
||||||
|
}
|
||||||
|
thCls = cx(thCls, col.HeaderClasses)
|
||||||
|
|
||||||
|
innerCls := cx(ptHeaderInnerBase, ptHeaderInnerPos[pos])
|
||||||
|
headerCells = append(headerCells, vdom.El("th",
|
||||||
|
vdom.Attr("class", thCls),
|
||||||
|
vdom.El("div", vdom.Attr("class", ptHeaderContent),
|
||||||
|
vdom.El("div", vdom.Attr("class", innerCls),
|
||||||
|
vdom.El("div", vdom.Attr("class", cx("grow text-sm", ptHeaderTextCls[opts.Color])),
|
||||||
|
vdom.Text(col.DisplayName),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
thead := vdom.El("thead",
|
||||||
|
vdom.Attr("class", "[&_th]:border-b [&_th]:border-neutral-300"),
|
||||||
|
vdom.El("tr", kids(nil, headerCells)...),
|
||||||
|
)
|
||||||
|
|
||||||
|
tbody := vdom.El("tbody", kids([]vdom.Mod{vdom.Attr("class", prettyTableBodyClass(opts))}, children)...)
|
||||||
|
|
||||||
|
return vdom.El("div", vdom.Attr("class", containerCls),
|
||||||
|
vdom.El("div", vdom.Attr("class", ptTblWrapper),
|
||||||
|
vdom.El("table", vdom.Attr("class", tableCls), thead, tbody),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
52
go/webui/remoteupdateflash.go
Normal file
52
go/webui/remoteupdateflash.go
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
// Port of web/kit/RemoteUpdateFlash.tsx.
|
||||||
|
|
||||||
|
package webui
|
||||||
|
|
||||||
|
import "kjol/vdom"
|
||||||
|
|
||||||
|
// RemoteFlash is the trigger/signal pair from createRemoteFlash: Fire() shows
|
||||||
|
// the flash, Visible() reports whether it is currently shown, and Clear() hides
|
||||||
|
// it. Create one with NewRemoteFlash and render RemoteUpdateFlash(flash.Visible())
|
||||||
|
// wherever the pill should appear.
|
||||||
|
type RemoteFlash struct {
|
||||||
|
visible *vdom.Signal[bool]
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRemoteFlash creates a flash controller.
|
||||||
|
//
|
||||||
|
// NOTE: the TSX createRemoteFlash auto-clears after durationMs via setTimeout.
|
||||||
|
// The neutral runtime has no timer, so the auto-clear is dropped — the caller
|
||||||
|
// must call Clear() when the flash should end (durationMs is retained only for
|
||||||
|
// API/documentation parity).
|
||||||
|
func NewRemoteFlash(durationMs int) *RemoteFlash {
|
||||||
|
_ = durationMs
|
||||||
|
return &RemoteFlash{visible: vdom.NewSignal(false)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Visible reports whether the flash is currently showing.
|
||||||
|
func (f *RemoteFlash) Visible() bool { return f.visible.Get() }
|
||||||
|
|
||||||
|
// Fire shows the flash (call when a remote update arrives).
|
||||||
|
func (f *RemoteFlash) Fire() { f.visible.Set(true) }
|
||||||
|
|
||||||
|
// Clear hides the flash.
|
||||||
|
func (f *RemoteFlash) Clear() { f.visible.Set(false) }
|
||||||
|
|
||||||
|
const remoteUpdateFlashCls = "remote-update-flash inline-flex items-center gap-1 rounded-full bg-emerald-100 border border-emerald-300 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-widest text-emerald-700"
|
||||||
|
|
||||||
|
// RemoteUpdateFlash is a small pill that briefly shows "Updated". It renders
|
||||||
|
// nothing (nil) when when is false, mirroring the TSX <Show when=…>.
|
||||||
|
func RemoteUpdateFlash(when bool) *vdom.VNode {
|
||||||
|
if !when {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return vdom.El("div",
|
||||||
|
vdom.Attr("class", remoteUpdateFlashCls),
|
||||||
|
vdom.El("svg",
|
||||||
|
vdom.Attr("viewBox", "0 0 12 12"),
|
||||||
|
vdom.Attr("class", "w-2.5 h-2.5 fill-current"),
|
||||||
|
vdom.Raw(`<circle cx="6" cy="6" r="6"/>`),
|
||||||
|
),
|
||||||
|
vdom.Text("Updated"),
|
||||||
|
)
|
||||||
|
}
|
||||||
131
go/webui/sidebar.go
Normal file
131
go/webui/sidebar.go
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
// Port of web/kit/Sidebar.tsx.
|
||||||
|
|
||||||
|
package webui
|
||||||
|
|
||||||
|
import "kjol/vdom"
|
||||||
|
|
||||||
|
const sidebarNavRoot = "bg-white rounded-default shadow-sm border border-neutral-200 py-2"
|
||||||
|
const sidebarNavList = "list-none m-0 p-0"
|
||||||
|
const sidebarNavBtn = "w-full text-left py-2 pr-3 pl-4 text-sm cursor-pointer bg-transparent text-neutral-600 border-0 hover:text-neutral-900 hover:bg-neutral-50"
|
||||||
|
const sidebarNavSubBtn = "w-full text-left py-1.5 pr-3 pl-8 text-xs cursor-pointer bg-transparent text-neutral-500 border-0 hover:text-neutral-900 hover:bg-neutral-50"
|
||||||
|
const sidebarNavIcon = "mr-2"
|
||||||
|
|
||||||
|
const sidebarLayoutRoot = "grid grid-cols-1 gap-8 min-h-screen items-start lg:grid-cols-12"
|
||||||
|
const sidebarLayoutSidebar = "hidden lg:block lg:col-span-2 lg:self-start lg:h-full"
|
||||||
|
const sidebarLayoutSticky = "sticky top-20 max-h-[calc(100vh_-_7rem)] overflow-y-auto"
|
||||||
|
const sidebarLayoutStickyFull = "sticky top-0 h-screen overflow-y-auto"
|
||||||
|
|
||||||
|
// sidebarLayoutMain: lg:pr-8 mirrors the grid's gap-8 so the content has matching
|
||||||
|
// breathing room on the right instead of hugging the viewport edge.
|
||||||
|
const sidebarLayoutMain = "col-span-1 min-w-0 lg:col-span-10 lg:pr-8"
|
||||||
|
|
||||||
|
// SidebarNavItem is one entry in a SidebarNav. Icon is an optional leading node
|
||||||
|
// (e.g. webui.Icon(...)). Children are optional second-level items that jump to
|
||||||
|
// sub-sections within this item.
|
||||||
|
type SidebarNavItem struct {
|
||||||
|
ID string
|
||||||
|
Label string
|
||||||
|
Icon *vdom.VNode
|
||||||
|
Children []SidebarNavItem
|
||||||
|
}
|
||||||
|
|
||||||
|
// SidebarNav renders a vertical nav of jump links. onItemClick receives the id
|
||||||
|
// of the clicked item (top-level or sub-item).
|
||||||
|
//
|
||||||
|
// NOTE: the TSX handler also does document.getElementById(id).scrollIntoView({
|
||||||
|
// behavior: "smooth" }) to smooth-scroll to the target section. That is
|
||||||
|
// browser-only (no document in the neutral runtime) and is dropped here; perform
|
||||||
|
// the scroll from within the app's onItemClick if needed.
|
||||||
|
func SidebarNav(items []SidebarNavItem, onItemClick func(string), class string) *vdom.VNode {
|
||||||
|
handleClick := func(id string) {
|
||||||
|
if onItemClick != nil {
|
||||||
|
onItemClick(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
listMods := []vdom.Mod{vdom.Attr("class", sidebarNavList)}
|
||||||
|
for _, item := range items {
|
||||||
|
id := item.ID
|
||||||
|
btnMods := []vdom.Mod{
|
||||||
|
vdom.Attr("class", sidebarNavBtn),
|
||||||
|
vdom.On(vdom.EVENT_CLICK, func() { handleClick(id) }),
|
||||||
|
}
|
||||||
|
if item.Icon != nil {
|
||||||
|
btnMods = append(btnMods, vdom.El("span", vdom.Attr("class", sidebarNavIcon), item.Icon))
|
||||||
|
}
|
||||||
|
btnMods = append(btnMods, vdom.Text(item.Label))
|
||||||
|
|
||||||
|
liMods := []vdom.Mod{vdom.El("button", btnMods...)}
|
||||||
|
|
||||||
|
if len(item.Children) > 0 {
|
||||||
|
subListMods := []vdom.Mod{vdom.Attr("class", sidebarNavList)}
|
||||||
|
for _, sub := range item.Children {
|
||||||
|
sid := sub.ID
|
||||||
|
subBtnMods := []vdom.Mod{
|
||||||
|
vdom.Attr("class", sidebarNavSubBtn),
|
||||||
|
vdom.On(vdom.EVENT_CLICK, func() { handleClick(sid) }),
|
||||||
|
}
|
||||||
|
if sub.Icon != nil {
|
||||||
|
subBtnMods = append(subBtnMods, vdom.El("span", vdom.Attr("class", sidebarNavIcon), sub.Icon))
|
||||||
|
}
|
||||||
|
subBtnMods = append(subBtnMods, vdom.Text(sub.Label))
|
||||||
|
subListMods = append(subListMods, vdom.El("li", vdom.El("button", subBtnMods...)))
|
||||||
|
}
|
||||||
|
liMods = append(liMods, vdom.El("ul", subListMods...))
|
||||||
|
}
|
||||||
|
|
||||||
|
listMods = append(listMods, vdom.El("li", liMods...))
|
||||||
|
}
|
||||||
|
|
||||||
|
return vdom.El("nav",
|
||||||
|
vdom.Attr("class", cx(sidebarNavRoot, class)),
|
||||||
|
vdom.El("ul", listMods...),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SidebarLayoutProps configures SidebarLayout. Sidebar is the aside content;
|
||||||
|
// children (variadic on SidebarLayout) are the main content.
|
||||||
|
type SidebarLayoutProps struct {
|
||||||
|
Sidebar *vdom.VNode
|
||||||
|
Class string
|
||||||
|
FullHeight bool
|
||||||
|
Collapsible bool
|
||||||
|
OnToggleCollapse func()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SidebarLayout is a two-column responsive grid: a sticky sidebar (aside) and a
|
||||||
|
// main content column. Children are the main content.
|
||||||
|
//
|
||||||
|
// NOTE: the TSX keeps an internal `collapsed` signal toggled by the collapse
|
||||||
|
// button, but nothing in its markup reads it — so collapsing has no visual
|
||||||
|
// effect there. This port renders the toggle button when Collapsible is set and
|
||||||
|
// wires OnToggleCollapse; the app owns any collapsed state/behavior.
|
||||||
|
func SidebarLayout(p SidebarLayoutProps, children ...*vdom.VNode) *vdom.VNode {
|
||||||
|
sticky := sidebarLayoutSticky
|
||||||
|
if p.FullHeight {
|
||||||
|
sticky = sidebarLayoutStickyFull
|
||||||
|
}
|
||||||
|
|
||||||
|
asideMods := []vdom.Mod{
|
||||||
|
vdom.Attr("class", sidebarLayoutSidebar),
|
||||||
|
vdom.El("div", kids([]vdom.Mod{vdom.Attr("class", sticky)}, []*vdom.VNode{p.Sidebar})...),
|
||||||
|
}
|
||||||
|
if p.Collapsible {
|
||||||
|
btnMods := []vdom.Mod{
|
||||||
|
vdom.Attr("type", "button"),
|
||||||
|
vdom.Attr("aria-label", "Toggle sidebar"),
|
||||||
|
}
|
||||||
|
if p.OnToggleCollapse != nil {
|
||||||
|
btnMods = append(btnMods, vdom.On(vdom.EVENT_CLICK, p.OnToggleCollapse))
|
||||||
|
}
|
||||||
|
asideMods = append(asideMods, vdom.El("button", btnMods...))
|
||||||
|
}
|
||||||
|
|
||||||
|
main := vdom.El("main", kids([]vdom.Mod{vdom.Attr("class", sidebarLayoutMain)}, children)...)
|
||||||
|
|
||||||
|
return vdom.El("div",
|
||||||
|
vdom.Attr("class", cx(sidebarLayoutRoot, p.Class)),
|
||||||
|
vdom.El("aside", asideMods...),
|
||||||
|
main,
|
||||||
|
)
|
||||||
|
}
|
||||||
147
go/webui/tabs.go
Normal file
147
go/webui/tabs.go
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
package webui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"kjol/vdom"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Port of web/kit/Tabs.tsx.
|
||||||
|
//
|
||||||
|
// NOTE: The TSX persisted the active index in localStorage (storageKey) and
|
||||||
|
// synced sibling tab groups via synthetic "storage" events on mount. Those are
|
||||||
|
// browser-only side effects with no neutral-runtime equivalent, so they are
|
||||||
|
// dropped: selection collapses to a plain ActiveIndex value plus an OnTabChange
|
||||||
|
// callback (the caller owns the state, per the porting guide). The TSX
|
||||||
|
// defaultIndex prop likewise collapses into the caller-supplied ActiveIndex.
|
||||||
|
|
||||||
|
const tabsBase = "flex items-center gap-1.5 cursor-pointer py-2 px-4 text-sm font-medium bg-transparent border-0 border-b-2 transition-[color,border-color] duration-150"
|
||||||
|
const tabsInactive = "text-text-muted border-neutral-200 hover:text-text-body"
|
||||||
|
const tabsActive = "text-primary border-primary"
|
||||||
|
|
||||||
|
// TabItem is one tab: a title, an optional numeric badge (shown only when > 0),
|
||||||
|
// and optional inline panel content.
|
||||||
|
type TabItem struct {
|
||||||
|
Title string
|
||||||
|
Badge int
|
||||||
|
Content *vdom.VNode
|
||||||
|
}
|
||||||
|
|
||||||
|
// TabGroupProps configures TabGroup. ActiveIndex is the selected tab; clicking a
|
||||||
|
// tab calls OnTabChange with its index (the caller updates ActiveIndex).
|
||||||
|
type TabGroupProps struct {
|
||||||
|
Items []TabItem
|
||||||
|
ActiveIndex int
|
||||||
|
OnTabChange func(int)
|
||||||
|
// Actions is optional content rendered on the right side of the tab bar.
|
||||||
|
Actions *vdom.VNode
|
||||||
|
// Class adds extra classes on the root (e.g. "ui-tabs" for structured panel
|
||||||
|
// CSS, "page-tabs" for tighter spacing).
|
||||||
|
Class string
|
||||||
|
// Fill makes tabs fill available height with internally-scrolling panels.
|
||||||
|
Fill bool
|
||||||
|
// Stretch is tri-state (nil = unset): when Actions is nil it defaults to
|
||||||
|
// true, otherwise it defaults to false — matching the TSX prop semantics.
|
||||||
|
Stretch *bool
|
||||||
|
PageTabs bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// TabGroup renders a horizontal tab bar with optional inline panels.
|
||||||
|
func TabGroup(p TabGroupProps) *vdom.VNode {
|
||||||
|
structured := p.Fill || strings.Contains(p.Class, "ui-tabs")
|
||||||
|
pageTabs := p.PageTabs || strings.Contains(p.Class, "page-tabs")
|
||||||
|
|
||||||
|
var rootCls string
|
||||||
|
switch {
|
||||||
|
case p.Fill:
|
||||||
|
rootCls = cx("ui-tabs flex w-full min-h-0 flex-1 flex-col overflow-hidden", p.Class)
|
||||||
|
case pageTabs:
|
||||||
|
rootCls = cx("w-full page-tabs", p.Class)
|
||||||
|
default:
|
||||||
|
rootCls = cx("w-full pb-4", p.Class)
|
||||||
|
}
|
||||||
|
|
||||||
|
headerCls := "overflow-x-auto flex flex-row w-full text-sm"
|
||||||
|
if structured {
|
||||||
|
headerCls = "header overflow-x-auto flex flex-row w-full shrink-0 text-sm"
|
||||||
|
}
|
||||||
|
|
||||||
|
stretchTabs := false
|
||||||
|
if p.Actions != nil {
|
||||||
|
stretchTabs = p.Stretch != nil && *p.Stretch
|
||||||
|
} else {
|
||||||
|
stretchTabs = p.Stretch == nil || *p.Stretch
|
||||||
|
}
|
||||||
|
stretchCls := ""
|
||||||
|
if stretchTabs {
|
||||||
|
stretchCls = "flex-1 justify-center md:flex-initial md:justify-start"
|
||||||
|
}
|
||||||
|
|
||||||
|
panelCls := func(index int) string {
|
||||||
|
active := index == p.ActiveIndex
|
||||||
|
if !structured {
|
||||||
|
if active {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return "hidden"
|
||||||
|
}
|
||||||
|
if !active {
|
||||||
|
return "panel hidden"
|
||||||
|
}
|
||||||
|
if p.Fill {
|
||||||
|
return "panel flex min-h-0 flex-1 flex-col overflow-hidden"
|
||||||
|
}
|
||||||
|
return "panel"
|
||||||
|
}
|
||||||
|
|
||||||
|
header := []vdom.Mod{vdom.Attr("class", headerCls)}
|
||||||
|
for i, item := range p.Items {
|
||||||
|
idx := i
|
||||||
|
state := tabsInactive
|
||||||
|
if i == p.ActiveIndex {
|
||||||
|
state = tabsActive
|
||||||
|
}
|
||||||
|
btn := []vdom.Mod{
|
||||||
|
vdom.Attr("type", "button"),
|
||||||
|
vdom.Attr("class", cx(tabsBase, stretchCls, state)),
|
||||||
|
vdom.On(vdom.EVENT_CLICK, func() {
|
||||||
|
if p.OnTabChange != nil {
|
||||||
|
p.OnTabChange(idx)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
vdom.Text(item.Title),
|
||||||
|
}
|
||||||
|
if item.Badge > 0 {
|
||||||
|
btn = append(btn, vdom.El("span",
|
||||||
|
vdom.Attr("class", "inline-flex items-center justify-center min-w-5 h-5 px-1 text-xs font-semibold bg-primary text-white rounded-full"),
|
||||||
|
vdom.Text(strconv.Itoa(item.Badge))))
|
||||||
|
}
|
||||||
|
header = append(header, vdom.El("button", btn...))
|
||||||
|
}
|
||||||
|
if p.Actions != nil {
|
||||||
|
header = append(header, vdom.El("div",
|
||||||
|
vdom.Attr("class", "tab-actions flex-1 self-end border-b-2 border-neutral-200 flex items-center justify-end pb-1"),
|
||||||
|
vdom.El("div", vdom.Attr("class", "flex items-center min-w-0"), p.Actions),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
root := []vdom.Mod{vdom.Attr("class", rootCls), vdom.El("div", header...)}
|
||||||
|
|
||||||
|
hasInlinePanels := false
|
||||||
|
for _, item := range p.Items {
|
||||||
|
if item.Content != nil {
|
||||||
|
hasInlinePanels = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if hasInlinePanels {
|
||||||
|
for i, item := range p.Items {
|
||||||
|
if item.Content == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
root = append(root, vdom.El("div", vdom.Attr("class", panelCls(i)), item.Content))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return vdom.El("div", root...)
|
||||||
|
}
|
||||||
171
go/webui/toast.go
Normal file
171
go/webui/toast.go
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
package webui
|
||||||
|
|
||||||
|
import "kjol/vdom"
|
||||||
|
|
||||||
|
// Port of web/kit/Toast.tsx.
|
||||||
|
//
|
||||||
|
// NOTE: Solid's context API (useToast + addToast/success/error/warning/info/
|
||||||
|
// generic) is dropped — there is no context or portal in the neutral runtime.
|
||||||
|
// Callers own the []Toast list and its removal instead: build the toasts, pass
|
||||||
|
// them to ToastProvider, and handle OnDismiss to drop one by ID. The
|
||||||
|
// auto-generated toast IDs (generateId) become the caller's responsibility.
|
||||||
|
// NOTE: auto-dismiss timers, the requestAnimationFrame progress countdown, and
|
||||||
|
// the exit (fade/slide) animation are dropped (no timers/rAF here). The progress
|
||||||
|
// bar, when shown, renders full-width and static; the dismiss button removes the
|
||||||
|
// toast immediately.
|
||||||
|
// NOTE: the "circle-xmark" and "circle-info" icons are not in the default icon
|
||||||
|
// registry and render as empty boxes until an app registers them.
|
||||||
|
|
||||||
|
// ToastType selects a toast's accent color and leading icon.
|
||||||
|
type ToastType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ToastSuccess ToastType = "success"
|
||||||
|
ToastError ToastType = "error"
|
||||||
|
ToastWarning ToastType = "warning"
|
||||||
|
ToastInfo ToastType = "info"
|
||||||
|
ToastGeneric ToastType = "generic"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ToastPosition is where the container is anchored on screen.
|
||||||
|
type ToastPosition string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ToastTopRight ToastPosition = "top-right"
|
||||||
|
ToastTopLeft ToastPosition = "top-left"
|
||||||
|
ToastBottomRight ToastPosition = "bottom-right"
|
||||||
|
ToastBottomLeft ToastPosition = "bottom-left"
|
||||||
|
ToastTopCenter ToastPosition = "top-center"
|
||||||
|
ToastBottomCenter ToastPosition = "bottom-center"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Toast is one notification. Duration is in ms (0 = no auto-dismiss and no
|
||||||
|
// progress bar). Dismissible shows the close button; ShowProgress shows the
|
||||||
|
// (static) progress bar when Duration > 0. Type defaults to ToastInfo.
|
||||||
|
type Toast struct {
|
||||||
|
ID string
|
||||||
|
Message string
|
||||||
|
Type ToastType
|
||||||
|
Duration int
|
||||||
|
Dismissible bool
|
||||||
|
ShowProgress bool
|
||||||
|
}
|
||||||
|
|
||||||
|
var toastTypeIcons = map[ToastType]string{
|
||||||
|
ToastSuccess: "circle-check",
|
||||||
|
ToastError: "circle-xmark",
|
||||||
|
ToastWarning: "triangle-exclamation",
|
||||||
|
ToastInfo: "circle-info",
|
||||||
|
ToastGeneric: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
const toastContainerBase = "fixed z-[200] flex flex-col gap-2"
|
||||||
|
|
||||||
|
var toastContainerPositions = map[ToastPosition]string{
|
||||||
|
ToastTopRight: "top-4 right-4",
|
||||||
|
ToastTopLeft: "top-4 left-4",
|
||||||
|
ToastBottomRight: "bottom-4 right-4 flex-col-reverse",
|
||||||
|
ToastBottomLeft: "bottom-4 left-4 flex-col-reverse",
|
||||||
|
ToastTopCenter: "top-4 left-1/2 -translate-x-1/2",
|
||||||
|
ToastBottomCenter: "bottom-4 left-1/2 -translate-x-1/2 flex-col-reverse",
|
||||||
|
}
|
||||||
|
|
||||||
|
const toastBase = "relative overflow-hidden rounded-default shadow-lg border border-neutral-200 border-l-4 bg-white min-w-72 max-w-md transition-[opacity,transform] duration-150 ease-out"
|
||||||
|
|
||||||
|
var toastTypeBorder = map[ToastType]string{
|
||||||
|
ToastSuccess: "border-l-green-700",
|
||||||
|
ToastError: "border-l-red-700",
|
||||||
|
ToastWarning: "border-l-yellow-500",
|
||||||
|
ToastInfo: "border-l-sky-800",
|
||||||
|
ToastGeneric: "border-l-neutral-400",
|
||||||
|
}
|
||||||
|
|
||||||
|
var toastIconColor = map[ToastType]string{
|
||||||
|
ToastSuccess: "text-green-600",
|
||||||
|
ToastError: "text-red-600",
|
||||||
|
ToastWarning: "text-yellow-600",
|
||||||
|
ToastInfo: "text-sky-700",
|
||||||
|
ToastGeneric: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToastItem renders a single toast. onDismiss receives the toast's ID when the
|
||||||
|
// close button is pressed.
|
||||||
|
func ToastItem(t Toast, onDismiss func(string)) *vdom.VNode {
|
||||||
|
typ := t.Type
|
||||||
|
if typ == "" {
|
||||||
|
typ = ToastInfo
|
||||||
|
}
|
||||||
|
icon := toastTypeIcons[typ]
|
||||||
|
showProgress := t.ShowProgress && t.Duration > 0
|
||||||
|
|
||||||
|
row := []vdom.Mod{vdom.Attr("class", "flex items-start gap-3 p-4")}
|
||||||
|
if icon != "" {
|
||||||
|
row = append(row, Icon(icon, 20, cx("shrink-0 mt-0.5", toastIconColor[typ])))
|
||||||
|
}
|
||||||
|
row = append(row, vdom.El("div", vdom.Attr("class", "flex-1 text-sm text-neutral-800"), vdom.Text(t.Message)))
|
||||||
|
if t.Dismissible {
|
||||||
|
dismiss := []vdom.Mod{
|
||||||
|
vdom.Attr("class", "shrink-0 cursor-pointer text-neutral-400 hover:text-neutral-600 bg-transparent border-0 p-0 transition-colors"),
|
||||||
|
vdom.Attr("aria-label", "Dismiss"),
|
||||||
|
}
|
||||||
|
if onDismiss != nil {
|
||||||
|
id := t.ID
|
||||||
|
dismiss = append(dismiss, vdom.On(vdom.EVENT_CLICK, func() { onDismiss(id) }))
|
||||||
|
}
|
||||||
|
dismiss = append(dismiss, Icon("xmark", 16, ""))
|
||||||
|
row = append(row, vdom.El("button", dismiss...))
|
||||||
|
}
|
||||||
|
|
||||||
|
mods := []vdom.Mod{
|
||||||
|
vdom.Attr("class", cx(toastBase, toastTypeBorder[typ])),
|
||||||
|
vdom.Attr("role", "alert"),
|
||||||
|
vdom.El("div", row...),
|
||||||
|
}
|
||||||
|
if showProgress {
|
||||||
|
mods = append(mods, vdom.El("div", vdom.Attr("class", "h-1 w-full bg-neutral-100"),
|
||||||
|
vdom.El("div", vdom.Attr("class", "h-full bg-neutral-300"), vdom.Attr("style", "width:100%")),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
return vdom.El("div", mods...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToastProviderProps configures ToastProvider. Position defaults to
|
||||||
|
// ToastBottomRight; MaxToasts defaults to 5 (only the most recent are kept).
|
||||||
|
type ToastProviderProps struct {
|
||||||
|
Position ToastPosition
|
||||||
|
MaxToasts int
|
||||||
|
Toasts []Toast
|
||||||
|
OnDismiss func(string)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToastProvider renders its children followed by the fixed toast container. It
|
||||||
|
// replaces the TSX ToastContext.Provider; the wrapper uses display:contents so
|
||||||
|
// it introduces no layout box of its own.
|
||||||
|
func ToastProvider(p ToastProviderProps, children ...*vdom.VNode) *vdom.VNode {
|
||||||
|
position := p.Position
|
||||||
|
if position == "" {
|
||||||
|
position = ToastBottomRight
|
||||||
|
}
|
||||||
|
max := p.MaxToasts
|
||||||
|
if max <= 0 {
|
||||||
|
max = 5
|
||||||
|
}
|
||||||
|
toasts := p.Toasts
|
||||||
|
if len(toasts) > max {
|
||||||
|
toasts = toasts[len(toasts)-max:]
|
||||||
|
}
|
||||||
|
|
||||||
|
container := []vdom.Mod{
|
||||||
|
vdom.Attr("class", cx(toastContainerBase, toastContainerPositions[position])),
|
||||||
|
vdom.Attr("aria-live", "polite"),
|
||||||
|
vdom.Attr("aria-label", "Notifications"),
|
||||||
|
}
|
||||||
|
for _, t := range toasts {
|
||||||
|
container = append(container, ToastItem(t, p.OnDismiss))
|
||||||
|
}
|
||||||
|
|
||||||
|
mods := []vdom.Mod{vdom.Attr("class", "contents")}
|
||||||
|
mods = kids(mods, children)
|
||||||
|
mods = append(mods, vdom.El("div", container...))
|
||||||
|
return vdom.El("div", mods...)
|
||||||
|
}
|
||||||
65
go/webui/toggleswitch.go
Normal file
65
go/webui/toggleswitch.go
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
package webui
|
||||||
|
|
||||||
|
import "kjol/vdom"
|
||||||
|
|
||||||
|
// Port of web/kit/ToggleSwitch.tsx. Reactive accessors collapse to plain values.
|
||||||
|
|
||||||
|
// ToggleSwitch renders an on/off <button role="switch"> with an optional label
|
||||||
|
// and description. onChange receives the next checked state.
|
||||||
|
func ToggleSwitch(checked bool, onChange func(bool), label, description string, disabled bool, class string) *vdom.VNode {
|
||||||
|
toggle := func() {
|
||||||
|
if disabled {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onChange(!checked)
|
||||||
|
}
|
||||||
|
|
||||||
|
trackState := "bg-neutral-300"
|
||||||
|
if checked {
|
||||||
|
trackState = "bg-primary"
|
||||||
|
}
|
||||||
|
trackCls := cx("relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors focus-visible:outline-2 focus-visible:outline-primary focus-visible:outline-offset-2 disabled:cursor-not-allowed disabled:opacity-50", trackState)
|
||||||
|
|
||||||
|
knobState := "translate-x-0.5"
|
||||||
|
if checked {
|
||||||
|
knobState = "translate-x-[18px]"
|
||||||
|
}
|
||||||
|
knobCls := cx("inline-block h-4 w-4 transform rounded-full bg-white shadow-sm transition-transform", knobState)
|
||||||
|
|
||||||
|
ariaChecked := "false"
|
||||||
|
if checked {
|
||||||
|
ariaChecked = "true"
|
||||||
|
}
|
||||||
|
btnMods := []vdom.Mod{
|
||||||
|
vdom.Attr("type", "button"),
|
||||||
|
vdom.Attr("role", "switch"),
|
||||||
|
vdom.Attr("aria-checked", ariaChecked),
|
||||||
|
vdom.Attr("class", trackCls),
|
||||||
|
vdom.On(vdom.EVENT_CLICK, toggle),
|
||||||
|
vdom.El("span", vdom.Attr("class", knobCls)),
|
||||||
|
}
|
||||||
|
if disabled {
|
||||||
|
btnMods = append(btnMods, vdom.Attr("disabled", "disabled"))
|
||||||
|
}
|
||||||
|
|
||||||
|
mods := []vdom.Mod{vdom.Attr("class", cx("flex items-center gap-2", class)), vdom.El("button", btnMods...)}
|
||||||
|
|
||||||
|
if label != "" || description != "" {
|
||||||
|
text := []vdom.Mod{vdom.Attr("class", "flex flex-col leading-tight")}
|
||||||
|
if label != "" {
|
||||||
|
labelColor := "text-neutral-800"
|
||||||
|
if disabled {
|
||||||
|
labelColor = "text-neutral-400"
|
||||||
|
}
|
||||||
|
text = append(text, vdom.El("span",
|
||||||
|
vdom.Attr("class", cx("text-sm select-none", labelColor)),
|
||||||
|
vdom.On(vdom.EVENT_CLICK, toggle),
|
||||||
|
vdom.Text(label)))
|
||||||
|
}
|
||||||
|
if description != "" {
|
||||||
|
text = append(text, vdom.El("span", vdom.Attr("class", "text-xs text-neutral-500"), vdom.Text(description)))
|
||||||
|
}
|
||||||
|
mods = append(mods, vdom.El("div", text...))
|
||||||
|
}
|
||||||
|
return vdom.El("div", mods...)
|
||||||
|
}
|
||||||
101
go/webui/tooltips.go
Normal file
101
go/webui/tooltips.go
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
// Port of web/kit/Tooltips.tsx.
|
||||||
|
//
|
||||||
|
// NOTE: the TSX builds tooltips on Floating.tsx (floating-ui-style measured
|
||||||
|
// positioning with flip/shift, a Portal, a FloatingContext, and hover/focus
|
||||||
|
// open-delay timers). The neutral runtime has none of that, so this port keeps
|
||||||
|
// the API + Tailwind + arrow markup and instead reveals the bubble purely in CSS
|
||||||
|
// via the Tailwind `group` marker (group-hover / group-focus-within). Placement
|
||||||
|
// is approximated with static absolute utility classes (no flip/shift and no
|
||||||
|
// measured coordinates), the numeric offset collapses to the fixed ~8px m*-2 gap
|
||||||
|
// (the TSX default), and the open delay is dropped.
|
||||||
|
|
||||||
|
package webui
|
||||||
|
|
||||||
|
import "kjol/vdom"
|
||||||
|
|
||||||
|
const tooltipCls = "bg-neutral-800 text-white text-sm px-2.5 py-1.5 rounded-default shadow-lg max-w-80 relative"
|
||||||
|
|
||||||
|
// tooltipArrowCls returns the arrow classes for a tooltip on the given base side
|
||||||
|
// (the arrow points back toward the trigger). Ported verbatim from arrowCls.
|
||||||
|
func tooltipArrowCls(base string) string {
|
||||||
|
const common = "absolute w-0 h-0"
|
||||||
|
switch base {
|
||||||
|
case "top":
|
||||||
|
return common + " -bottom-[6px] left-1/2 -translate-x-1/2 border-l-[6px] border-l-transparent border-r-[6px] border-r-transparent border-t-[6px] border-t-neutral-800"
|
||||||
|
case "bottom":
|
||||||
|
return common + " -top-[6px] left-1/2 -translate-x-1/2 border-l-[6px] border-l-transparent border-r-[6px] border-r-transparent border-b-[6px] border-b-neutral-800"
|
||||||
|
case "left":
|
||||||
|
return common + " -right-[6px] top-1/2 -translate-y-1/2 border-t-[6px] border-t-transparent border-b-[6px] border-b-transparent border-l-[6px] border-l-neutral-800"
|
||||||
|
case "right":
|
||||||
|
return common + " -left-[6px] top-1/2 -translate-y-1/2 border-t-[6px] border-t-transparent border-b-[6px] border-b-transparent border-r-[6px] border-r-neutral-800"
|
||||||
|
default:
|
||||||
|
return common
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// tooltipPlacementCls positions the bubble relative to the trigger wrapper.
|
||||||
|
func tooltipPlacementCls(placement string) string {
|
||||||
|
switch placement {
|
||||||
|
case "bottom":
|
||||||
|
return "top-full left-1/2 -translate-x-1/2 mt-2"
|
||||||
|
case "left":
|
||||||
|
return "right-full top-1/2 -translate-y-1/2 mr-2"
|
||||||
|
case "right":
|
||||||
|
return "left-full top-1/2 -translate-y-1/2 ml-2"
|
||||||
|
default: // "top"
|
||||||
|
return "bottom-full left-1/2 -translate-x-1/2 mb-2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// tooltipRevealCls returns the CSS reveal classes for the given trigger mode.
|
||||||
|
func tooltipRevealCls(trigger string) string {
|
||||||
|
if trigger == "focus" {
|
||||||
|
return "invisible opacity-0 transition-opacity group-focus-within:visible group-focus-within:opacity-100"
|
||||||
|
}
|
||||||
|
return "invisible opacity-0 transition-opacity group-hover:visible group-hover:opacity-100"
|
||||||
|
}
|
||||||
|
|
||||||
|
// TooltipProps configures Tooltip. Trigger is "hover" (default) or "focus";
|
||||||
|
// Placement is "top" (default), "bottom", "left" or "right".
|
||||||
|
type TooltipProps struct {
|
||||||
|
Content *vdom.VNode
|
||||||
|
Trigger string
|
||||||
|
Placement string
|
||||||
|
Class string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tooltip wraps children (the trigger) and shows Content in a floating bubble on
|
||||||
|
// hover (or keyboard focus when Trigger is "focus").
|
||||||
|
func Tooltip(p TooltipProps, children ...*vdom.VNode) *vdom.VNode {
|
||||||
|
placement := pick(p.Placement, "top")
|
||||||
|
|
||||||
|
bubble := vdom.El("div",
|
||||||
|
vdom.Attr("role", "tooltip"),
|
||||||
|
vdom.Attr("data-floating-content", "true"),
|
||||||
|
vdom.Attr("class", cx("absolute z-[110]", tooltipPlacementCls(placement), tooltipCls, tooltipRevealCls(p.Trigger))),
|
||||||
|
)
|
||||||
|
if p.Content != nil {
|
||||||
|
bubble.Children = append(bubble.Children, p.Content)
|
||||||
|
}
|
||||||
|
bubble.Children = append(bubble.Children, vdom.El("div", vdom.Attr("class", tooltipArrowCls(placement))))
|
||||||
|
|
||||||
|
// The wrapper carries `group` + `tabindex` so both hover and keyboard-focus
|
||||||
|
// reveal work in CSS. inline-block mirrors the TSX trigger's display.
|
||||||
|
mods := []vdom.Mod{
|
||||||
|
vdom.Attr("class", cx("group relative inline-block", p.Class)),
|
||||||
|
vdom.Attr("tabindex", "0"),
|
||||||
|
}
|
||||||
|
mods = kids(mods, children)
|
||||||
|
mods = append(mods, bubble)
|
||||||
|
return vdom.El("span", mods...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HoverTooltip is Tooltip with hover reveal (the TSX default variant).
|
||||||
|
func HoverTooltip(content *vdom.VNode, placement, class string, children ...*vdom.VNode) *vdom.VNode {
|
||||||
|
return Tooltip(TooltipProps{Content: content, Trigger: "hover", Placement: placement, Class: class}, children...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FocusTooltip is Tooltip revealed on keyboard focus.
|
||||||
|
func FocusTooltip(content *vdom.VNode, placement, class string, children ...*vdom.VNode) *vdom.VNode {
|
||||||
|
return Tooltip(TooltipProps{Content: content, Trigger: "focus", Placement: placement, Class: class}, children...)
|
||||||
|
}
|
||||||
188
go/webui/tutorial.go
Normal file
188
go/webui/tutorial.go
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
package webui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"kjol/vdom"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Port of web/kit/Tutorial.tsx.
|
||||||
|
//
|
||||||
|
// Tutorial is a guided-tour / coachmark overlay: it dims the page, spotlights a
|
||||||
|
// target element, and floats a popover card with step navigation. The Solid
|
||||||
|
// source leans heavily on browser-only capabilities the neutral vdom runtime
|
||||||
|
// does not have (DOM measurement via getBoundingClientRect, portals, effects,
|
||||||
|
// timers, scroll/resize listeners, window sizing). What is ported vs. dropped:
|
||||||
|
//
|
||||||
|
// - NOTE: calculatePopoverPosition — the viewport-aware placement math that
|
||||||
|
// anchors and flips the popover around the target rect — is DROPPED. The
|
||||||
|
// popover is statically centered with Tailwind instead of computed coords.
|
||||||
|
// - NOTE: SpotlightOverlay's measured cutout (a giant box-shadow ring drawn
|
||||||
|
// around the target's DOMRect) is APPROXIMATED by a plain dimmed backdrop,
|
||||||
|
// which is the source's own no-target fallback.
|
||||||
|
// - NOTE: PopoverArrow (the little triangle pointing at the target) is DROPPED,
|
||||||
|
// since there is no target position to point at.
|
||||||
|
// - NOTE: the fade/scale/slide transitions and the requestAnimationFrame /
|
||||||
|
// setTimeout choreography are DROPPED; the card renders in its final state.
|
||||||
|
// - NOTE: Solid context (TutorialProvider/useTutorial) + signals collapse to
|
||||||
|
// plain props — the caller owns the active flag, the current-step index, and
|
||||||
|
// the next/prev/close callbacks (read at the call site, as per kit convention).
|
||||||
|
// - NOTE: per-step onEnter/onLeave lifecycle hooks (effect-driven) are DROPPED,
|
||||||
|
// as is the string/function/null target union — Target here is a plain CSS
|
||||||
|
// selector kept for reference only (nothing measures or scrolls to it).
|
||||||
|
// - NOTE: SpotlightPadding, Placement, and Offset are retained for API parity
|
||||||
|
// but are unused, because there is no positioning/spotlight to apply them to.
|
||||||
|
|
||||||
|
// TutorialStep is one stop in a guided tour. Content is the body VNode (the TSX
|
||||||
|
// JSXElement). Target is the CSS selector of the element the step would spotlight
|
||||||
|
// (see file NOTE — not measured here). Placement/Offset are kept for API parity
|
||||||
|
// but are not applied.
|
||||||
|
type TutorialStep struct {
|
||||||
|
Title string
|
||||||
|
Content *vdom.VNode
|
||||||
|
Target string
|
||||||
|
Placement string
|
||||||
|
Offset int
|
||||||
|
}
|
||||||
|
|
||||||
|
// TutorialProps drives the tour overlay. CurrentStep is the active index as a
|
||||||
|
// plain value (the state that was a Solid signal now lives with the caller).
|
||||||
|
// Active gates whether the overlay renders at all. OnNext/OnPrev advance the
|
||||||
|
// tour; OnClose ends it (shared by the backdrop click, the close button, and the
|
||||||
|
// Finish button on the last step).
|
||||||
|
type TutorialProps struct {
|
||||||
|
Steps []TutorialStep
|
||||||
|
CurrentStep int
|
||||||
|
Active bool
|
||||||
|
SpotlightPadding int
|
||||||
|
OnNext func()
|
||||||
|
OnPrev func()
|
||||||
|
OnClose func()
|
||||||
|
Class string
|
||||||
|
}
|
||||||
|
|
||||||
|
// tutorialOverlay is the dimmed backdrop. NOTE: this approximates SpotlightOverlay
|
||||||
|
// without the measured spotlight cutout (see file NOTE); clicking it ends the tour.
|
||||||
|
func tutorialOverlay(p TutorialProps) *vdom.VNode {
|
||||||
|
mods := []vdom.Mod{vdom.Attr("class", "fixed inset-0 bg-black/50 z-150")}
|
||||||
|
if p.OnClose != nil {
|
||||||
|
mods = append(mods, vdom.On(vdom.EVENT_CLICK, p.OnClose))
|
||||||
|
}
|
||||||
|
return vdom.El("div", mods...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// tutorialPopover renders the tooltip card (title + "X of N" + close, body,
|
||||||
|
// prev/next controls, and the step dots) for step idx. NOTE: it is statically
|
||||||
|
// centered rather than anchored to the target (see file NOTE).
|
||||||
|
func tutorialPopover(p TutorialProps, idx int) *vdom.VNode {
|
||||||
|
step := p.Steps[idx]
|
||||||
|
total := len(p.Steps)
|
||||||
|
|
||||||
|
// Header: optional title + step counter, and a close button.
|
||||||
|
headerLeft := []vdom.Mod{vdom.Attr("class", "flex items-center gap-2")}
|
||||||
|
if step.Title != "" {
|
||||||
|
headerLeft = append(headerLeft, vdom.El("span",
|
||||||
|
vdom.Attr("class", "font-medium text-neutral-900"),
|
||||||
|
vdom.Text(step.Title)))
|
||||||
|
}
|
||||||
|
headerLeft = append(headerLeft, vdom.El("span",
|
||||||
|
vdom.Attr("class", "text-xs text-neutral-500"),
|
||||||
|
vdom.Text(strconv.Itoa(idx+1)+" of "+strconv.Itoa(total))))
|
||||||
|
|
||||||
|
closeMods := []vdom.Mod{
|
||||||
|
vdom.Attr("type", "button"),
|
||||||
|
vdom.Attr("class", "cursor-pointer text-neutral-400 bg-transparent border-0 p-0 leading-none transition-colors hover:text-neutral-600"),
|
||||||
|
Icon("xmark", 18, ""),
|
||||||
|
}
|
||||||
|
if p.OnClose != nil {
|
||||||
|
closeMods = append(closeMods, vdom.On(vdom.EVENT_CLICK, p.OnClose))
|
||||||
|
}
|
||||||
|
header := vdom.El("div", vdom.Attr("class", "flex items-center justify-between p-4 pb-2"),
|
||||||
|
vdom.El("div", headerLeft...),
|
||||||
|
vdom.El("button", closeMods...),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Body content.
|
||||||
|
bodyMods := []vdom.Mod{vdom.Attr("class", "px-4 pb-4 text-sm text-neutral-700")}
|
||||||
|
if step.Content != nil {
|
||||||
|
bodyMods = append(bodyMods, step.Content)
|
||||||
|
}
|
||||||
|
body := vdom.El("div", bodyMods...)
|
||||||
|
|
||||||
|
// Controls: Previous on the left (hidden on the first step); Next/Finish right.
|
||||||
|
isFirst := idx == 0
|
||||||
|
isLast := idx == total-1
|
||||||
|
|
||||||
|
leftMods := []vdom.Mod{}
|
||||||
|
if !isFirst {
|
||||||
|
leftMods = append(leftMods, Button(ButtonProps{Color: ButtonWhite, Small: true, Text: "Previous", OnClick: p.OnPrev}))
|
||||||
|
}
|
||||||
|
var advance *vdom.VNode
|
||||||
|
if isLast {
|
||||||
|
advance = Button(ButtonProps{Color: ButtonBlue, Small: true, Text: "Finish", OnClick: p.OnClose})
|
||||||
|
} else {
|
||||||
|
advance = Button(ButtonProps{Color: ButtonBlue, Small: true, Text: "Next", OnClick: p.OnNext})
|
||||||
|
}
|
||||||
|
controls := vdom.El("div", vdom.Attr("class", "flex items-center justify-between px-4 pb-4 gap-2"),
|
||||||
|
vdom.El("div", leftMods...),
|
||||||
|
vdom.El("div", vdom.Attr("class", "flex gap-2"), advance),
|
||||||
|
)
|
||||||
|
|
||||||
|
// NOTE: original card class kept verbatim; the centering utilities
|
||||||
|
// (left/top/-translate) are the static stand-in for computed positioning.
|
||||||
|
popMods := []vdom.Mod{
|
||||||
|
vdom.Attr("class", cx("fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-200 bg-white rounded-default shadow-lg border border-neutral-200 max-w-sm", p.Class)),
|
||||||
|
header, body, controls,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step dots (only when there is more than one step).
|
||||||
|
if total > 1 {
|
||||||
|
dotMods := []vdom.Mod{vdom.Attr("class", "flex justify-center gap-1.5 pb-3")}
|
||||||
|
for i := 0; i < total; i++ {
|
||||||
|
state := "bg-neutral-300"
|
||||||
|
if i == idx {
|
||||||
|
state = "bg-sky-600 scale-110"
|
||||||
|
}
|
||||||
|
dotMods = append(dotMods, vdom.El("div",
|
||||||
|
vdom.Attr("class", cx("w-2 h-2 rounded-full transition-all duration-300 ease-in-out", state))))
|
||||||
|
}
|
||||||
|
popMods = append(popMods, vdom.El("div", dotMods...))
|
||||||
|
}
|
||||||
|
|
||||||
|
return vdom.El("div", popMods...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TutorialProvider renders children, and — when the tour is Active — the dimmed
|
||||||
|
// backdrop plus the popover card for the current step over the top. The wrapper
|
||||||
|
// uses display:contents so it does not introduce its own layout box (the Solid
|
||||||
|
// source returned a fragment). Returns just the children when inactive or empty.
|
||||||
|
//
|
||||||
|
// NOTE: unlike the Solid provider, children cannot read tour state via context;
|
||||||
|
// the caller threads Active/CurrentStep/callbacks in through TutorialProps.
|
||||||
|
func TutorialProvider(p TutorialProps, children ...*vdom.VNode) *vdom.VNode {
|
||||||
|
mods := kids([]vdom.Mod{vdom.Attr("class", "contents")}, children)
|
||||||
|
|
||||||
|
if p.Active && len(p.Steps) > 0 {
|
||||||
|
idx := p.CurrentStep
|
||||||
|
if idx < 0 {
|
||||||
|
idx = 0
|
||||||
|
}
|
||||||
|
if idx > len(p.Steps)-1 {
|
||||||
|
idx = len(p.Steps) - 1
|
||||||
|
}
|
||||||
|
mods = append(mods, tutorialOverlay(p), tutorialPopover(p, idx))
|
||||||
|
}
|
||||||
|
|
||||||
|
return vdom.El("div", mods...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartTutorialButton is a blue button that kicks off the tour. onStart is the
|
||||||
|
// caller's start handler (which decides the starting step index). With no
|
||||||
|
// children it renders the default "Start Tutorial" label.
|
||||||
|
func StartTutorialButton(onStart func(), class string, children ...*vdom.VNode) *vdom.VNode {
|
||||||
|
bp := ButtonProps{Color: ButtonBlue, Class: class, OnClick: onStart}
|
||||||
|
if len(children) == 0 {
|
||||||
|
bp.Text = "Start Tutorial"
|
||||||
|
}
|
||||||
|
return Button(bp, children...)
|
||||||
|
}
|
||||||
147
go/webui/validation.go
Normal file
147
go/webui/validation.go
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
// Port of web/kit/Validation.ts.
|
||||||
|
package webui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
// validationNonDigit matches every non-digit rune (the JS /\D/g).
|
||||||
|
var validationNonDigit = regexp.MustCompile(`\D`)
|
||||||
|
|
||||||
|
// validationDigits strips every non-digit character.
|
||||||
|
func validationDigits(s string) string {
|
||||||
|
return validationNonDigit.ReplaceAllString(s, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validation describes a single form field's validation inputs. Solid's reactive
|
||||||
|
// Accessor<T> values collapse to plain values here: the caller reads its signals
|
||||||
|
// at the call site and passes the current values. FieldBlur is optional (nil
|
||||||
|
// falls back to Field), as is IsValidFunc.
|
||||||
|
type Validation struct {
|
||||||
|
ID string // snake_case identifier that is "touched"
|
||||||
|
Name string // human-readable field name, used in messages
|
||||||
|
Required bool // whether an empty value is an error
|
||||||
|
Touched map[string]bool // which field IDs the user has interacted with
|
||||||
|
Field string // the live field value
|
||||||
|
FieldBlur *string // the value at last blur; nil uses Field
|
||||||
|
IsValidFunc func(string) bool
|
||||||
|
InvalidMsg string // custom "is invalid" message; empty uses a default
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateValidation computes the validation error message for a field, or "" when
|
||||||
|
// valid. This is the collapsed (non-reactive) form of the Solid createMemo: the
|
||||||
|
// caller re-invokes it whenever the underlying signals change.
|
||||||
|
func CreateValidation(v Validation) string {
|
||||||
|
field := strings.TrimSpace(v.Field)
|
||||||
|
// When no blur value is provided, fall back to the live value so the
|
||||||
|
// "has value but not blurred yet" guard is always false and validation
|
||||||
|
// runs against the live value instead.
|
||||||
|
fieldBlur := field
|
||||||
|
if v.FieldBlur != nil {
|
||||||
|
fieldBlur = strings.TrimSpace(*v.FieldBlur)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !v.Touched[v.ID] || (field != "" && fieldBlur == "") || (v.IsValidFunc != nil && v.IsValidFunc(field)) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if v.Required && field == "" {
|
||||||
|
return v.Name + " is required"
|
||||||
|
}
|
||||||
|
if v.IsValidFunc != nil && !v.IsValidFunc(fieldBlur) {
|
||||||
|
if v.InvalidMsg != "" {
|
||||||
|
return v.InvalidMsg
|
||||||
|
}
|
||||||
|
return v.Name + " is invalid"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsPhoneNumberValid reports whether phoneNumber has exactly 10 digits.
|
||||||
|
func IsPhoneNumberValid(phoneNumber string) bool {
|
||||||
|
return len(validationDigits(phoneNumber)) == 10
|
||||||
|
}
|
||||||
|
|
||||||
|
var validationEmailRegex = regexp.MustCompile("^[-!#$%&'*+/0-9=?A-Z^_a-z`{|}~](\\.?[-!#$%&'*+/0-9=?A-Z^_a-z`{|}~])*@[a-zA-Z0-9](-*\\.?[a-zA-Z0-9])*\\.[a-zA-Z](-?[a-zA-Z0-9])+$")
|
||||||
|
|
||||||
|
// IsEmailValid reports whether email is a syntactically valid email address.
|
||||||
|
func IsEmailValid(email string) bool {
|
||||||
|
if email == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
emailParts := strings.Split(email, "@")
|
||||||
|
if len(emailParts) != 2 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
account := emailParts[0]
|
||||||
|
address := emailParts[1]
|
||||||
|
|
||||||
|
if len(account) > 64 {
|
||||||
|
return false
|
||||||
|
} else if len(address) > 255 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, part := range strings.Split(address, ".") {
|
||||||
|
if len(part) > 63 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return validationEmailRegex.MatchString(email)
|
||||||
|
}
|
||||||
|
|
||||||
|
var validationURLRegex = regexp.MustCompile("[(http(s)?)://(www\\.)?a-zA-Z0-9@:%._\\+~#=]{2,256}\\.[a-z]{2,6}\\b([-a-zA-Z0-9@:%_\\+.~#?&//=]*)")
|
||||||
|
|
||||||
|
// IsURLValid reports whether url contains a substring that looks like a URL.
|
||||||
|
func IsURLValid(url string) bool {
|
||||||
|
return validationURLRegex.MatchString(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsZipCodeValid reports whether zip has exactly 5 or 9 digits.
|
||||||
|
func IsZipCodeValid(zip string) bool {
|
||||||
|
rawZip := validationDigits(zip)
|
||||||
|
return len(rawZip) == 5 || len(rawZip) == 9
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsTaxIDValid reports whether id has exactly 9 digits.
|
||||||
|
func IsTaxIDValid(id string) bool {
|
||||||
|
return len(validationDigits(id)) == 9
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsAtLeastMinChars reports whether input is at least minLen characters long.
|
||||||
|
func IsAtLeastMinChars(input string, minLen int) bool {
|
||||||
|
return utf8.RuneCountInString(input) >= minLen
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsWithinMaxChars reports whether input is at most maxLen characters long.
|
||||||
|
func IsWithinMaxChars(input string, maxLen int) bool {
|
||||||
|
return utf8.RuneCountInString(input) <= maxLen
|
||||||
|
}
|
||||||
|
|
||||||
|
var validationNameRegex = regexp.MustCompile(`^[\p{L}]*[\p{L} '\-]*[\p{L}]$`)
|
||||||
|
|
||||||
|
// IsNameValid reports whether name consists only of letters, spaces, hyphens,
|
||||||
|
// and apostrophes (and begins/ends with a letter).
|
||||||
|
func IsNameValid(name string) bool {
|
||||||
|
return validationNameRegex.MatchString(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
var validationUsernameRegex = regexp.MustCompile(`^[A-Za-z0-9]*$`)
|
||||||
|
|
||||||
|
// IsUsernameValid reports an error message when the username is invalid (5-50
|
||||||
|
// alphanumeric characters), or "" when valid.
|
||||||
|
func IsUsernameValid(username string) string {
|
||||||
|
if n := utf8.RuneCountInString(username); n < 5 || n > 50 {
|
||||||
|
return "Username must have 5-50 characters"
|
||||||
|
}
|
||||||
|
|
||||||
|
if !validationUsernameRegex.MatchString(username) {
|
||||||
|
return "Username must only contain alphanumeric characters"
|
||||||
|
}
|
||||||
|
|
||||||
|
return "" // Valid
|
||||||
|
}
|
||||||
64
go/webui/webui.go
Normal file
64
go/webui/webui.go
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
// Package webui is a Go/WebAssembly UI component kit for the gowasm engine — a
|
||||||
|
// direct port of kjol's Solid.js TSX kit (web/kit). Components are neutral
|
||||||
|
// *vdom.VNode builders: they render to HTML on the server (SSR) and hydrate on
|
||||||
|
// the client, styled with Tailwind utility classes.
|
||||||
|
//
|
||||||
|
// Port conventions (how the TSX maps to Go):
|
||||||
|
//
|
||||||
|
// - A TSX component `function Foo(props)` becomes `func Foo(p FooProps, children ...*vdom.VNode) *vdom.VNode`
|
||||||
|
// (children variadic when the component wraps content).
|
||||||
|
// - Solid's reactive accessors (`value | () => value`) collapse away: the whole
|
||||||
|
// component re-renders on a signal write (React-style), so callers pass plain
|
||||||
|
// current values — read a signal with `.Get()` at the call site.
|
||||||
|
// - `onclick={fn}` → `vdom.On(vdom.EVENT_CLICK, fn)`; handlers needing the event use
|
||||||
|
// `OnEvent`. `class` overrides are the trailing `Class` field, joined via cx.
|
||||||
|
// - Tailwind classes are copied verbatim so kjol/cmd/twcss (scanning these .go
|
||||||
|
// files) emits the matching CSS. Custom tokens (rounded-default, bg-primary,
|
||||||
|
// text-text-heading, …) come from the app's @theme block.
|
||||||
|
// - Browser-only behavior (floating-ui positioning, portals, focus traps,
|
||||||
|
// element measurement) has no equivalent in the neutral runtime; those
|
||||||
|
// components port their structure + Tailwind + signal/event wiring, and
|
||||||
|
// approximate positioning with CSS where possible. Such gaps are marked
|
||||||
|
// with a NOTE in the component's file.
|
||||||
|
package webui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"kjol/vdom"
|
||||||
|
)
|
||||||
|
|
||||||
|
// cx joins non-empty class fragments with single spaces (a tiny clsx). Trailing
|
||||||
|
// user `Class` overrides go last so they win under equal specificity.
|
||||||
|
func cx(parts ...string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
for _, p := range parts {
|
||||||
|
p = strings.TrimSpace(p)
|
||||||
|
if p == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if b.Len() > 0 {
|
||||||
|
b.WriteByte(' ')
|
||||||
|
}
|
||||||
|
b.WriteString(p)
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// pick returns v if non-empty, else def (for defaulted string props like type).
|
||||||
|
func pick(v, def string) string {
|
||||||
|
if v == "" {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// kids appends children VNodes onto a mod slice (helper for the props+children shape).
|
||||||
|
func kids(mods []vdom.Mod, children []*vdom.VNode) []vdom.Mod {
|
||||||
|
for _, c := range children {
|
||||||
|
if c != nil {
|
||||||
|
mods = append(mods, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return mods
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user