Files
kjol/go/cmd/examples/go-wasm-web/app/kit.go
2026-07-13 15:21:42 -04:00

230 lines
9.9 KiB
Go

package app
import (
"strings"
. "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),
)
}
// languageOptions is deliberately longer than the pill limit, so the multi-select
// demonstrates both ways it collapses: past 3 selections it says "N items selected"
// outright, and below that it still collapses if the pills are too wide for the field.
func languageOptions() []ui.FormSelectOption {
return []ui.FormSelectOption{
{Value: "go", Label: "Go"},
{Value: "rust", Label: "Rust"},
{Value: "ts", Label: "TypeScript"},
{Value: "python", Label: "Python"},
{Value: "kotlin", Label: "Kotlin"},
{Value: "swift", Label: "Swift"},
}
}
//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")
name := NewSignal("")
email := NewSignal("")
plan := NewSignal("pro")
langs := NewSignal([]string{"go"})
// Floating components are CONTROLLERS: they own refs, timers and open state, so
// they are built once here — never inside the render closure below, which would
// rebuild them (and lose their state) on every frame.
modal := ui.NewModal(ui.ModalOptions{Size: ui.ModalMedium})
menu := ui.NewMenu(ui.MenuOptions{Placement: ui.PlacementBottomStart})
tip := ui.NewHoverTooltip(ui.PlacementTop, "")
skills := ui.NewMultiSelect(ui.DropdownOptions{})
return func() *VNode {
return Div(Attr("class", "space-y-8"),
Div(
H2(Attr("class", "text-2xl font-semibold tracking-tight text-text-heading"), Text("UI Kit")),
P(Attr("class", "mt-1 text-neutral-500"),
Text("The kjol/webui components, ported from the Solid.js kit and styled with Tailwind. "+
"Interactive components are driven by signals; overlays and floating elements render "+
"in their static form (see the note at the bottom).")),
),
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))),
// A multi-select. Its rows carry checkboxes, and the field shows the
// selection as removable pills — until they stop fitting, at which point
// it collapses to "N items selected". Tick a few and watch it flip.
row("flex flex-col gap-1", ui.FormLabel(ui.FormLabelProps{}, Text("Languages")),
skills.Render(ui.FormMultiSelectProps{
Options: languageOptions(),
Value: langs.Get(),
Placeholder: "Pick a few",
Searchable: true,
ShowSelectAll: true,
OnChange: func(v []string) { langs.Set(v) },
})),
),
P(Attr("class", "text-xs text-neutral-500"),
Text("Live: name=\""+name.Get()+"\" email=\""+email.Get()+"\" plan=\""+plan.Get()+
"\" languages="+strings.Join(langs.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 (measured, portaled)",
row("flex flex-wrap items-center gap-4",
ui.Button(ui.ButtonProps{Color: ui.ButtonPrimary, Text: "Open modal", OnClick: modal.Open}),
// The menu measures itself against the viewport: drag the window
// narrow, or scroll it to the bottom, and it flips/shifts to stay on
// screen. Items close the menu themselves — no callback plumbing.
menu.TriggerFunc(ui.MenuTriggerProps{Tag: "div"}, func(open bool) *VNode {
caret := " ▾"
if open {
caret = " ▴"
}
return ui.Button(ui.ButtonProps{Color: ui.ButtonWhite, Text: "Menu" + caret})
}),
menu.Content("",
menu.Item(ui.MenuItemProps{Icon: "check"}, Text("Profile")),
menu.Item(ui.MenuItemProps{}, Text("Settings")),
ui.MenuDivider(""),
menu.Item(ui.MenuItemProps{}, Text("Sign out")),
),
// The tooltip's arrow tracks the trigger even when the panel gets
// shifted away from it near a viewport edge.
tip.Render(Span(Text("A measured tooltip — try it near the window edge")),
ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Text: "Hover me"})),
),
modal.Render(ui.ModalProps{
Header: H3(Attr("class", "text-lg font-semibold text-text-heading"), Text("Example modal")),
},
P(Attr("class", "text-neutral-600"),
Text("Portaled to document.body, so it is not clipped by any ancestor. Escape closes "+
"the topmost modal; the backdrop click closes too.")),
),
),
ui.Alert(ui.AlertGreen, "About this page",
Text("Menus, tooltips, modals and dropdowns are now really measured: they are portaled to "+
"document.body, positioned from getBoundingClientRect against the viewport, and they "+
"flip and shift to stay on screen. Resize the window or scroll while one is open.")),
)
}
}