1440 lines
62 KiB
Go
1440 lines
62 KiB
Go
package app
|
|
|
|
import (
|
|
"strings"
|
|
"time"
|
|
|
|
. "kjol/vdom"
|
|
"kjol/wasmruntime"
|
|
ui "kjol/webui"
|
|
)
|
|
|
|
// The whole kit, on one page.
|
|
//
|
|
// It used to be three pages — "UI kit", "Overlays", "AutoTable" — which is a split
|
|
// along the lines of the SOURCE FILES rather than along anything a reader wants. A
|
|
// person looking for a date picker does not know, and should not have to guess,
|
|
// whether it was filed under forms or under overlays. So: one page, one scroll, and a
|
|
// sidebar of groups that jumps you to the one you are after.
|
|
//
|
|
// The cost is a long page and a lot of live components on it at once. That is the
|
|
// right trade for documentation you search with ctrl-F.
|
|
|
|
// componentGroup is one section of the page AND one line in the sidebar. Declaring
|
|
// them once, as data, is what keeps those two in step — the sidebar cannot list a
|
|
// section that does not exist, and a section cannot go missing from the sidebar.
|
|
type componentGroup struct {
|
|
ID string // the anchor, and the docSection's element id
|
|
Label string
|
|
Icon string
|
|
Blurb string // shown on the /wasm index; too long for the sidebar
|
|
}
|
|
|
|
func componentGroups() []componentGroup {
|
|
return []componentGroup{
|
|
{ID: "buttons", Label: "Buttons", Icon: "check",
|
|
Blurb: "Colour, outline, size, icon. Segmented controls and link-buttons."},
|
|
{ID: "badges", Label: "Badges & alerts", Icon: "circle-info",
|
|
Blurb: "Solid badges, tinted alerts, and the environment badge that shows nothing in production."},
|
|
{ID: "cards", Label: "Cards & layout", Icon: "squares",
|
|
Blurb: "Cards, headers, dividers, crumb trails, a spinner, a code box."},
|
|
{ID: "icons", Label: "Icons", Icon: "cube",
|
|
Blurb: "The kit draws its own: stroke paths in Go, no FontAwesome anywhere near it."},
|
|
{ID: "forms", Label: "Forms & inputs", Icon: "pencil",
|
|
Blurb: "Controlled inputs, and masks that are idempotent because they are fed their own output."},
|
|
{ID: "selects", Label: "Selects & comboboxes", Icon: "filter",
|
|
Blurb: "Combobox, multi-select with collapsing pills, and an async one that discards stale responses."},
|
|
{ID: "toggles", Label: "Toggles & signature", Icon: "check",
|
|
Blurb: "Switches, and a signature pad whose value IS the SVG you drew."},
|
|
{ID: "dates", Label: "Dates", Icon: "calendar",
|
|
Blurb: "A picker you can type into — 7/4/26, Jul 4 2026 and 2026-07-04 all work."},
|
|
{ID: "tables", Label: "Tables", Icon: "table",
|
|
Blurb: "PrettyTable prints, CellGrid edits, AutoTable filters, sorts, calculates and exports."},
|
|
{ID: "overlays", Label: "Overlays", Icon: "layers",
|
|
Blurb: "Tooltips, popovers, menus, modals — measured, portaled, flipped to fit."},
|
|
{ID: "feedback", Label: "Toasts & tours", Icon: "bolt",
|
|
Blurb: "Self-dismissing toasts, a spotlight tour, and a flash for data somebody else changed."},
|
|
{ID: "navigation", Label: "Tabs & navigation", Icon: "bars",
|
|
Blurb: "Tabs, CRM tabs, accordions, and a sidebar of jump links."},
|
|
{ID: "search", Label: "Fuzzy search", Icon: "magnifying-glass",
|
|
Blurb: "Subsequence matching with typo tolerance, scored and highlighted."},
|
|
{ID: "charts", Label: "Charts", Icon: "chart-column",
|
|
Blurb: "SVG drawn in Go, on the server — in the HTML before any WebAssembly loads."},
|
|
{ID: "theming", Label: "Theming", Icon: "sun",
|
|
Blurb: "The token contract, and the switch. No component names a colour."},
|
|
}
|
|
}
|
|
|
|
//gowasm:page /wasm/components layout=app
|
|
func ComponentsPage(d Deps) func() *VNode {
|
|
// Shared across sections, so both the overlay menus and the toast buttons raise the
|
|
// same toasts — and there is one queue rather than two arguing about the corner.
|
|
toaster := ui.NewToaster(ui.ToasterOptions{Position: ui.ToastBottomRight})
|
|
push := func(kind ui.ToastType, msg string) { toaster.Push(ui.Toast{Message: msg, Type: kind}) }
|
|
|
|
// The tour targets sections BY CSS SELECTOR — the same ids the sidebar jumps to.
|
|
tour := ui.NewTutorial(ui.TutorialOptions{
|
|
Steps: []ui.TutorialStep{
|
|
{Title: "Buttons", Target: "#buttons",
|
|
Content: func() *VNode { return Text("The presentational end: a props struct in, a VNode out.") }},
|
|
{Title: "Overlays", Target: "#overlays",
|
|
Content: func() *VNode { return Text("Measured against the real viewport, portaled out of the tree.") }},
|
|
{Title: "Tables", Target: "#tables",
|
|
Content: func() *VNode { return Text("Filter, sort, calculate, export — from a column list.") }},
|
|
{Title: "That's the tour",
|
|
Content: func() *VNode { return Text("Escape ends it. Arrow keys and Enter move between steps.") }},
|
|
},
|
|
})
|
|
|
|
// Every section is a CLOSURE built once, here. The controllers inside them (menus,
|
|
// modals, pickers, the table) own refs, timers and open state — build one inside the
|
|
// render and it is thrown away and rebuilt every frame, so it can never stay open.
|
|
sections := []func() *VNode{
|
|
buttonsSection(),
|
|
badgesSection(),
|
|
cardsSection(),
|
|
iconsSection(),
|
|
formsSection(),
|
|
selectsSection(),
|
|
togglesSection(),
|
|
datesSection(),
|
|
tablesSection(),
|
|
overlaysSection(push),
|
|
feedbackSection(toaster, tour),
|
|
navigationSection(),
|
|
searchSection(),
|
|
chartsSection(),
|
|
themingSection(),
|
|
}
|
|
|
|
return func() *VNode {
|
|
body := []*VNode{
|
|
docSection("using", "Using a component",
|
|
prose("A component is a function taking a props struct. There is no class hierarchy and "+
|
|
"nothing to register: a component is a value, so you can build one, store it, pass it "+
|
|
"around, and the compiler will tell you when you got it wrong."),
|
|
prose("The floating ones — menus, modals, tooltips, pickers, the table — are CONTROLLERS "+
|
|
"instead. They own refs, timers and open state, so you build them once alongside your "+
|
|
"signals. Build one inside a render closure and it is discarded and rebuilt on every "+
|
|
"frame, which means it can never stay open long enough for you to see it."),
|
|
code("app/components.go", kitSnippet),
|
|
note("Styling is Tailwind, compiled from your Go",
|
|
"The Tailwind engine scans .go files for class names, because that is where the markup "+
|
|
"is. There is no JavaScript build anywhere in Kjøl Wasm Web — the CSS is "+
|
|
"compiled by a Go program, from Go source."),
|
|
row("mt-4 flex gap-2", tour.StartButton(0, "", Text("Take the tour"))),
|
|
),
|
|
}
|
|
for _, s := range sections {
|
|
body = append(body, s())
|
|
}
|
|
|
|
// Fixed-position hosts. Where they sit in the tree makes no difference to where
|
|
// they appear, so they go last, once.
|
|
body = append(body, toaster.Render(), tour.Render())
|
|
|
|
return docPage("Kjøl Wasm Web", "Components",
|
|
"Every component in kjol/webui, running. Not a screenshot of one anywhere: each block below "+
|
|
"is the real component, rendered by the same Go that renders it in an application. Use the "+
|
|
"sidebar to jump to a group.",
|
|
body...)
|
|
}
|
|
}
|
|
|
|
// ---- buttons -------------------------------------------------------------
|
|
|
|
func buttonsSection() func() *VNode {
|
|
clicks := NewSignal(0)
|
|
span := NewSignal("week")
|
|
|
|
return func() *VNode {
|
|
return docSection("buttons", "Buttons",
|
|
prose("Colour, outline, size and an optional icon, from one props struct. A button with an "+
|
|
"icon and no text gets square padding rather than the wide pill a text button gets — so "+
|
|
"an icon button is a square, not a lozenge with a picture rattling around in it."),
|
|
|
|
demo("Colours",
|
|
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"}),
|
|
ui.Button(ui.ButtonProps{Color: ui.ButtonWhite, Text: "White"}),
|
|
ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Text: "Light"}),
|
|
),
|
|
),
|
|
|
|
demo("Outline, size, icon, disabled",
|
|
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}),
|
|
),
|
|
),
|
|
|
|
demo("Click it — clicked "+itoa(clicks.Get())+" times",
|
|
row("flex flex-wrap items-center gap-3",
|
|
ui.Button(ui.ButtonProps{Color: ui.ButtonPrimary, Text: "Count",
|
|
OnClick: func() { clicks.Set(clicks.Get() + 1) }}),
|
|
ui.ButtonLink(func() { clicks.Set(0) }, Text("Reset (a link that is a button)")),
|
|
ui.BackLink("#components", "Back link", false),
|
|
),
|
|
),
|
|
|
|
demo("Segmented control — selected: "+span.Get(),
|
|
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"),
|
|
),
|
|
)
|
|
}
|
|
}
|
|
|
|
// ---- badges & alerts -----------------------------------------------------
|
|
|
|
func badgesSection() func() *VNode {
|
|
return func() *VNode {
|
|
return docSection("badges", "Badges & alerts",
|
|
prose("A badge is a solid fill with white text, so it stays legible in both themes without "+
|
|
"a variant on it. An alert is the opposite: a wash of colour behind dark text, which is "+
|
|
"exactly the case a token cannot carry into dark mode — a red-50 tint is invisible on a "+
|
|
"near-black surface — so alerts are one of only two places in the kit with a dark: rule."),
|
|
|
|
demo("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")),
|
|
),
|
|
),
|
|
|
|
demo("Alerts",
|
|
row("flex flex-col gap-3",
|
|
ui.Alert(ui.AlertBlue, "Heads up", Text("An informational message with a header.")),
|
|
ui.Alert(ui.AlertGreen, "", Text("A success alert, with no header.")),
|
|
ui.Alert(ui.AlertYellow, "Warning", Text("Something needs your attention.")),
|
|
ui.Alert(ui.AlertRed, "Error", Text("Something went wrong.")),
|
|
ui.Alert(ui.AlertGray, "", Text("And a neutral one.")),
|
|
),
|
|
),
|
|
|
|
demo("Environment badge",
|
|
row("flex flex-wrap items-center gap-3",
|
|
ui.EnvBadge("development"),
|
|
ui.EnvBadge("staging"),
|
|
// Production deliberately renders NOTHING — the badge exists to tell you that
|
|
// you are NOT in production, and a badge that is always there says nothing.
|
|
Span(Attr("class", "text-ss text-ink-muted"),
|
|
Text("(production renders nothing — that is the point of it)")),
|
|
),
|
|
),
|
|
)
|
|
}
|
|
}
|
|
|
|
// ---- cards & layout ------------------------------------------------------
|
|
|
|
func cardsSection() func() *VNode {
|
|
return func() *VNode {
|
|
return docSection("cards", "Cards & layout",
|
|
prose("The structural furniture: cards, headings, dividers, crumb trails, a spinner, and a "+
|
|
"code box. Nothing here holds state — they are containers, and they take their children."),
|
|
|
|
demo("Cards",
|
|
row("grid gap-4 sm:grid-cols-3",
|
|
ui.Card("", ui.CardHeader("", Text("Card")),
|
|
P(Attr("class", "text-sm text-ink-soft"), Text("Padded, and grows to fill its row."))),
|
|
ui.BorderCard("", ui.CardSubheader("", Text("BorderCard")),
|
|
P(Attr("class", "text-sm text-ink-soft"), Text("A border instead of a shadow."))),
|
|
ui.BorderCutCornerCard("", ui.CardSubheader("", Text("Cut corner")),
|
|
P(Attr("class", "text-sm text-ink-soft"), Text("The same, with a clipped corner."))),
|
|
),
|
|
),
|
|
|
|
demo("Headers, dividers, crumbs",
|
|
row("flex flex-col gap-2",
|
|
ui.Breadcrumbs([]ui.BreadcrumbItem{
|
|
{URL: "/", DisplayText: "kjol"},
|
|
{URL: "/wasm", DisplayText: "Kjøl Wasm Web"},
|
|
{URL: "/wasm/components", DisplayText: "Components"},
|
|
}),
|
|
ui.PageHeader("A page header", ""),
|
|
ui.Divider(),
|
|
ui.ManagerPageHeader("With a description and an action", "The three-part page heading.",
|
|
ui.Button(ui.ButtonProps{Color: ui.ButtonPrimary, Small: true, Text: "New"})),
|
|
),
|
|
),
|
|
|
|
demo("Loader, code box, links",
|
|
row("flex flex-wrap items-center gap-6",
|
|
ui.Loader(),
|
|
ui.PageLink("https://go.dev", true, "text-accent underline underline-offset-4",
|
|
Text("An external link (new tab)")),
|
|
),
|
|
ui.CodeBox("go run ./server -build\ngo run ./server", "mt-3"),
|
|
),
|
|
)
|
|
}
|
|
}
|
|
|
|
// ---- icons ---------------------------------------------------------------
|
|
|
|
func iconsSection() func() *VNode {
|
|
return func() *VNode {
|
|
names := []string{
|
|
"check", "xmark", "plus", "minus", "pencil", "trash", "filter", "download", "print",
|
|
"calendar", "envelope", "globe", "user", "book-open", "chart-column", "server", "table",
|
|
"code", "bolt", "cube", "shield-check", "sun", "moon", "magnifying-glass", "circle-info",
|
|
"circle-check", "circle-xmark", "triangle-exclamation", "chevron-down", "bars", "layers",
|
|
}
|
|
cells := []*VNode{}
|
|
for _, n := range names {
|
|
cells = append(cells, Div(Attr("class", "flex flex-col items-center gap-1.5 rounded-default border border-line px-2 py-3"),
|
|
ui.Icon(n, 18, "text-ink-soft"),
|
|
Span(Attr("class", "font-mono text-[10px] text-ink-faint"), Text(n)),
|
|
))
|
|
}
|
|
|
|
return docSection("icons", "Icons",
|
|
prose("The Go kit draws its own icons: a small registry of stroke paths, in Go, with no "+
|
|
"FontAwesome anywhere near it. That is a deliberate difference from the Solid kit, which "+
|
|
"generates a tree-shaken FontAwesome subset at build time — this side has no build step "+
|
|
"to hang that off, so it carries the few dozen glyphs it actually uses."),
|
|
prose("An icon the registry has never heard of renders an empty box, and a test fails the "+
|
|
"build over it rather than letting it ship."),
|
|
|
|
demo("The registry",
|
|
row("grid grid-cols-4 gap-2 sm:grid-cols-8", cells...),
|
|
),
|
|
|
|
demo("Variants",
|
|
row("flex flex-wrap items-center gap-5",
|
|
ui.IconContainer(ui.Icon("check", 16, ""), Span(Attr("class", "text-sm text-ink-soft"), Text("IconContainer"))),
|
|
ui.IconSuccess("circle-check", 18, ""),
|
|
ui.IconError("circle-xmark", 18, ""),
|
|
Span(Attr("class", "inline-flex items-center gap-1.5 text-sm text-ink-soft"),
|
|
ui.IconInline("bolt", 14, ""), Text("IconInline sits on the text baseline")),
|
|
),
|
|
),
|
|
apiTable(
|
|
apiRow{"ui.Icon(name, size, class)", "The icon. Size is height in px; the width follows the viewBox."},
|
|
apiRow{"ui.RegisterIcon", "Add your own. The kit ships a small set; an app brings the rest."},
|
|
),
|
|
)
|
|
}
|
|
}
|
|
|
|
// ---- forms ---------------------------------------------------------------
|
|
|
|
func formsSection() func() *VNode {
|
|
name := NewSignal("")
|
|
email := NewSignal("")
|
|
notes := NewSignal("")
|
|
plan := NewSignal("pro")
|
|
taxID := NewSignal("")
|
|
rate := NewSignal("")
|
|
money := NewSignal("")
|
|
pct := NewSignal("")
|
|
phone := NewSignal("")
|
|
zip := NewSignal("")
|
|
|
|
return func() *VNode {
|
|
field := func(label string, input *VNode) *VNode {
|
|
return row("flex flex-col gap-1", ui.FormLabel(ui.FormLabelProps{}, Text(label)), input)
|
|
}
|
|
|
|
return docSection("forms", "Forms & inputs",
|
|
prose("Inputs are controlled: the value goes in as a prop, the change comes out as a "+
|
|
"callback, and the caller owns the state. There is no two-way binding and nothing hidden "+
|
|
"inside the component — which is why the same input works on the server, where there is "+
|
|
"no one to type into it."),
|
|
|
|
demo("Text, email, textarea, select",
|
|
row("grid gap-4 sm:grid-cols-2",
|
|
field("Name", ui.FormInput(ui.FormInputProps{Value: name.Get(), Placeholder: "Ada Lovelace",
|
|
OnInput: func(v string) { name.Set(v) }})),
|
|
field("Email", ui.FormEmailInput(ui.FormInputProps{Value: email.Get(), Placeholder: "ada@example.com",
|
|
OnInput: func(v string) { email.Set(v) }}, true)),
|
|
field("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))),
|
|
field("Notes", ui.FormTextarea(ui.FormTextareaProps{Value: notes.Get(), Rows: "3",
|
|
Placeholder: "Anything worth remembering…", OnInput: func(v string) { notes.Set(v) }})),
|
|
),
|
|
),
|
|
|
|
demo("Masked inputs — type letters into them",
|
|
row("grid gap-4 sm:grid-cols-3",
|
|
// A mask is a pure function of the string, applied on every keystroke. It must
|
|
// be IDEMPOTENT — it is fed its own output — or the field corrupts itself as
|
|
// you type, one character at a time, and only for fast typists.
|
|
field("Tax ID", ui.FormInput(ui.FormInputProps{Value: taxID.Get(), Placeholder: "12-3456789",
|
|
OnInput: func(v string) { taxID.Set(ui.MaskTaxID(v)) }})),
|
|
field("Rate", ui.FormInput(ui.FormInputProps{Value: rate.Get(), Placeholder: "5.25",
|
|
OnInput: func(v string) { rate.Set(ui.MaskRate(v)) }})),
|
|
field("Currency", ui.FormCurrencyInput(ui.FormInputProps{Value: money.Get(),
|
|
OnInput: func(v string) { money.Set(v) }}, false)),
|
|
field("Percent", ui.FormPercentInput(ui.FormInputProps{Value: pct.Get(),
|
|
OnInput: func(v string) { pct.Set(v) }})),
|
|
field("Phone", ui.FormPhoneInput(ui.FormInputProps{Value: phone.Get(),
|
|
OnInput: func(v string) { phone.Set(v) }})),
|
|
field("Zip", ui.FormZipCodeInput(ui.FormInputProps{Value: zip.Get(),
|
|
OnInput: func(v string) { zip.Set(v) }})),
|
|
),
|
|
),
|
|
|
|
demo("Fieldset, file input, and a validation error",
|
|
ui.FormFieldset("Account", "",
|
|
row("grid gap-4 sm:grid-cols-2",
|
|
field("Attachment", ui.FormFileInput(ui.FormInputProps{})),
|
|
field("Email (validated)", ui.FormEmailInput(ui.FormInputProps{
|
|
Value: email.Get(),
|
|
Error: emailError(email.Get()),
|
|
OnInput: func(v string) { email.Set(v) },
|
|
}, true)),
|
|
),
|
|
),
|
|
),
|
|
)
|
|
}
|
|
}
|
|
|
|
// emailError is derived, not stored — so it cannot fall out of step with the field it
|
|
// describes. Blank is not "invalid", it is unfilled.
|
|
func emailError(v string) string {
|
|
if v != "" && !ui.IsEmailValid(v) {
|
|
return "That is not an email address."
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// ---- selects & comboboxes ------------------------------------------------
|
|
|
|
func selectsSection() func() *VNode {
|
|
langs := NewSignal([]string{"go"})
|
|
tags := NewSignal([]string{"go"})
|
|
one := NewSignal("")
|
|
state := NewSignal("")
|
|
tz := NewSignal("")
|
|
picked := NewSignal("")
|
|
|
|
combo := ui.NewCombobox(ui.DropdownOptions{})
|
|
skills := ui.NewMultiSelect(ui.DropdownOptions{})
|
|
tagPicker := ui.NewMultiSelectTrigger(ui.DropdownOptions{})
|
|
|
|
// The search is the CALLER's. The component knows how to debounce, order and render;
|
|
// it knows nothing at all about where options come from. Here it is a local slice; in
|
|
// an app it would be a fetch.
|
|
people := ui.NewAsyncCombobox(ui.AsyncComboboxOptions{
|
|
MinChars: 2,
|
|
Search: func(q string, done func([]ui.FormSelectOption)) {
|
|
var out []ui.FormSelectOption
|
|
for _, r := range employees() {
|
|
p := emp(r)
|
|
if strings.Contains(strings.ToLower(p.Name), strings.ToLower(q)) {
|
|
out = append(out, ui.FormSelectOption{Value: p.Email, Label: p.Name})
|
|
}
|
|
}
|
|
done(out)
|
|
},
|
|
})
|
|
|
|
return func() *VNode {
|
|
field := func(label string, input *VNode) *VNode {
|
|
return row("flex flex-col gap-1", ui.FormLabel(ui.FormLabelProps{}, Text(label)), input)
|
|
}
|
|
|
|
return docSection("selects", "Selects & comboboxes",
|
|
prose("A combobox picks one; a multi-select picks several, with checkboxes on the rows and "+
|
|
"removable pills in the field. Past three selections — or once the pills stop fitting — "+
|
|
"the field collapses to \"N items selected\" rather than growing until it wraps."),
|
|
|
|
demo("Combobox, multi-select, and the built-in selectors",
|
|
row("grid gap-4 sm:grid-cols-2",
|
|
field("Language (searchable, one)", combo.Render(ui.FormComboboxProps{
|
|
Options: languageOptions(), Value: one.Get(), Placeholder: "Pick one",
|
|
Searchable: true, OnChange: func(v string) { one.Set(v) },
|
|
})),
|
|
field("Languages (several)", skills.Render(ui.FormMultiSelectProps{
|
|
Options: languageOptions(), Value: langs.Get(), Placeholder: "Pick a few",
|
|
Searchable: true, ShowSelectAll: true, OnChange: func(v []string) { langs.Set(v) },
|
|
})),
|
|
field("State", ui.FormStateSelector(ui.FormSelectProps{
|
|
Value: state.Get(), OnChange: func(v string) { state.Set(v) }})),
|
|
field("Timezone", ui.FormTimezoneSelector(ui.FormSelectProps{
|
|
Value: tz.Get(), OnChange: func(v string) { tz.Set(v) }})),
|
|
),
|
|
P(Attr("class", "mt-3 text-ss text-ink-muted"),
|
|
Text("one="+orElse(one.Get(), "—")+" several="+orElse(strings.Join(langs.Get(), ","), "—"))),
|
|
),
|
|
|
|
demo("Async combobox — picked: "+orElse(picked.Get(), "nothing yet"),
|
|
row("max-w-sm",
|
|
people.Render(ui.FormAsyncComboboxProps{
|
|
Placeholder: "Search people…",
|
|
OnSelect: func(o ui.FormSelectOption) { picked.Set(o.Label + " <" + o.Value + ">") },
|
|
}),
|
|
),
|
|
P(Attr("class", "mt-3 text-ss text-ink-muted"),
|
|
Text("Two characters before it asks; 200 ms after you stop typing. A response for a query "+
|
|
"you have already typed past is DISCARDED rather than shown — which is the whole bug "+
|
|
"with hand-rolled autocompletes.")),
|
|
),
|
|
|
|
demo("Multi-select behind your own trigger",
|
|
tagPicker.Render(ui.FormMultiSelectTriggerProps{
|
|
Trigger: ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Small: true,
|
|
Icon: "filter", Text: "Tags (" + itoa(len(tags.Get())) + ")"}),
|
|
Options: languageOptions(),
|
|
Value: tags.Get(),
|
|
Searchable: true,
|
|
ShowSelectAll: true,
|
|
OnChange: func(v []string) { tags.Set(v) },
|
|
}),
|
|
P(Attr("class", "mt-3 text-ss text-ink-muted"),
|
|
Text("Same selection model as the field above; only the thing you click on differs.")),
|
|
),
|
|
)
|
|
}
|
|
}
|
|
|
|
// ---- toggles & signature -------------------------------------------------
|
|
|
|
func togglesSection() func() *VNode {
|
|
notify := NewSignal(true)
|
|
locked := NewSignal(false)
|
|
signed := NewSignal("")
|
|
|
|
pad := ui.NewSignaturePad(ui.SignaturePadOptions{
|
|
OnChange: func(svg string) { signed.Set(svg) },
|
|
})
|
|
|
|
return func() *VNode {
|
|
return docSection("toggles", "Toggles & signature",
|
|
prose("A toggle is a checkbox that admits what it is. The signature pad is an SVG rather "+
|
|
"than a canvas, which is not a detail: the markup you draw IS the value the caller gets, "+
|
|
"so a stored signature renders on the server like any other markup."),
|
|
|
|
demo("Toggles",
|
|
row("flex flex-col gap-4",
|
|
ui.ToggleSwitch(notify.Get(), func(v bool) { notify.Set(v) },
|
|
"Email notifications", "At most one message a day.", false, ""),
|
|
ui.ToggleSwitch(locked.Get(), func(v bool) { locked.Set(v) },
|
|
"Locked", "This one is disabled.", true, ""),
|
|
),
|
|
),
|
|
|
|
demo("Signature pad — "+itoa(len(signed.Get()))+" bytes of SVG",
|
|
pad.Render(ui.SignaturePadProps{}),
|
|
P(Attr("class", "mt-3 text-ss text-ink-muted"),
|
|
Text("Draw in it. Long strokes are smoothed; a single stray point is not a stroke and "+
|
|
"is dropped, so a click does not leave a dot behind.")),
|
|
),
|
|
)
|
|
}
|
|
}
|
|
|
|
// ---- dates ---------------------------------------------------------------
|
|
|
|
func datesSection() func() *VNode {
|
|
picked := NewSignal("")
|
|
day := NewSignal("")
|
|
month := NewSignal(time.Time{})
|
|
|
|
dp := ui.NewDatePicker(ui.DatePickerProps{
|
|
Placeholder: "Pick a date",
|
|
Clearable: true,
|
|
OnChange: func(v string) { picked.Set(v) },
|
|
})
|
|
dob := ui.NewDateOfBirthPicker(ui.DatePickerProps{Placeholder: "Date of birth"})
|
|
|
|
return func() *VNode {
|
|
return docSection("dates", "Dates",
|
|
prose("The field is TYPEABLE, not merely clickable. It parses loosely — 7/4/26, Jul 4 2026 "+
|
|
"and 2026-07-04 all work — and commits what it understood on blur, so the calendar is an "+
|
|
"affordance rather than the only way in. A picker you can only click is a picker that is "+
|
|
"slower than the keyboard for everyone who knows the date already."),
|
|
|
|
demo("Pickers — picked: "+orElse(picked.Get(), "nothing"),
|
|
row("grid gap-4 sm:grid-cols-2",
|
|
row("flex flex-col gap-1",
|
|
ui.FormLabel(ui.FormLabelProps{}, Text("Date (portaled; flips near the bottom)")),
|
|
dp.Render()),
|
|
row("flex flex-col gap-1",
|
|
ui.FormLabel(ui.FormLabelProps{}, Text("Date of birth (inline, three selects)")),
|
|
dob.Render()),
|
|
),
|
|
),
|
|
|
|
demo("The calendar on its own — selected: "+orElse(day.Get(), "none"),
|
|
row("max-w-xs",
|
|
ui.Calendar(ui.CalendarProps{
|
|
Selected: day.Get(),
|
|
ViewMonth: month.Get(),
|
|
OnSelect: func(k string) { day.Set(k) },
|
|
OnNavigate: func(m time.Time) { month.Set(m) },
|
|
}),
|
|
),
|
|
P(Attr("class", "mt-3 text-ss text-ink-muted"),
|
|
Text("The same grid the picker drops down, usable directly when you want it inline.")),
|
|
),
|
|
)
|
|
}
|
|
}
|
|
|
|
// ---- tables --------------------------------------------------------------
|
|
|
|
func tablesSection() func() *VNode {
|
|
highlight := NewSignal("")
|
|
|
|
table := newEmployeeTable(highlight)
|
|
table.SetRows(employees())
|
|
|
|
exportMenu := ui.NewMenu(ui.MenuOptions{Placement: ui.PlacementBottomEnd})
|
|
pdfSub := ui.NewSubmenu(exportMenu)
|
|
|
|
// CellGrid is the editable one: a spreadsheet, not a report.
|
|
gridRows := NewSignal([]map[string]any{
|
|
{"id": "1", "sku": "KJ-100", "qty": 12, "price": "$4.50"},
|
|
{"id": "2", "sku": "KJ-220", "qty": 3, "price": "$18.00"},
|
|
{"id": "3", "sku": "KJ-330", "qty": 47, "price": "$1.25"},
|
|
})
|
|
gridSort := NewSignal("sku")
|
|
gridDesc := NewSignal(false)
|
|
|
|
pdfHeader := func(landscape bool) ui.AutoTablePDFHeader {
|
|
orientation := ui.PDF_ORIENTATION_PORTRAIT
|
|
if landscape {
|
|
orientation = ui.PDF_ORIENTATION_LANDSCAPE
|
|
}
|
|
return ui.AutoTablePDFHeader{
|
|
Title: "Employees",
|
|
Subtitle: "Exported from the kjol-website components page",
|
|
ShowDate: true,
|
|
Orientation: orientation,
|
|
}
|
|
}
|
|
|
|
return func() *VNode {
|
|
return docSection("tables", "Tables",
|
|
prose("Three of them, and the difference is what the user is allowed to do. PrettyTable "+
|
|
"prints rows. CellGrid lets them be edited in place. AutoTable filters, sorts, pages, "+
|
|
"reorders, resizes, computes and exports — configured with a column list and a slice of "+
|
|
"rows, and everything the user changes about it is theirs and persists."),
|
|
|
|
// --- PrettyTable
|
|
demo("PrettyTable — it prints rows, and that is all",
|
|
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"))),
|
|
),
|
|
),
|
|
|
|
// --- CellGrid
|
|
demo("CellGrid — editable cells",
|
|
ui.CellGrid(ui.CellGridProps{
|
|
Columns: []ui.CellGridColumn{
|
|
{Key: "sku", Label: "SKU", SortKey: "sku"},
|
|
{Key: "qty", Label: "Qty", SortKey: "qty", SortType: ui.SortTypeNumeric, Editable: true},
|
|
{Key: "price", Label: "Price", SortKey: "price", SortType: ui.SortTypeMoney, Editable: true},
|
|
},
|
|
Rows: gridRows.Get(),
|
|
IDField: "id",
|
|
OnCellChange: func(id any, field string, v any) {
|
|
rows := gridRows.Get()
|
|
next := make([]map[string]any, len(rows))
|
|
for i, r := range rows {
|
|
cp := map[string]any{}
|
|
for k, val := range r {
|
|
cp[k] = val
|
|
}
|
|
if cp["id"] == id {
|
|
cp[field] = v
|
|
}
|
|
next[i] = cp
|
|
}
|
|
gridRows.Set(next)
|
|
},
|
|
SortKey: gridSort.Get(),
|
|
SetSortKey: func(k string) { gridSort.Set(k) },
|
|
SortDesc: gridDesc.Get(),
|
|
SetSortDesc: func(b bool) { gridDesc.Set(b) },
|
|
}),
|
|
P(Attr("class", "mt-3 text-ss text-ink-muted"),
|
|
Text("Click a Qty or Price cell and type. The grid does not own the rows — it tells you "+
|
|
"which cell changed and hands the value back; what you do with it is yours.")),
|
|
),
|
|
|
|
// --- AutoTable
|
|
prose("AutoTable is the big one. Search matches name or email. Sort by Salary and it parses "+
|
|
"the currency, so $980 sorts below $1,200.50. Unhide Rank and sort that: \"Item 2\" comes "+
|
|
"before \"Item 10\", because numbers inside text are compared as numbers. Drag a header to "+
|
|
"reorder it, drag its right edge to resize — reload the page and both are still where you "+
|
|
"left them."),
|
|
prose("Then filter it and export. You get every matching row across every page, in the column "+
|
|
"order you dragged them into, with the calculated columns computed per row — not the five "+
|
|
"rows that happened to be on screen."),
|
|
|
|
table.Render(
|
|
ui.AutoTableWithHover(),
|
|
ui.AutoTableWithAlternate(),
|
|
ui.AutoTableWithSurroundingBorder(),
|
|
ui.AutoTableWithPaginationShowAll(),
|
|
ui.AutoTableWithSearchFields(
|
|
table.GlobalSearch("Search name or email…", "Name", "Email"),
|
|
table.SelectSearch("Status", []string{"active", "inactive"}, "Any status"),
|
|
table.MultiSelectSearch("Team", "Any team", []string{"Engineering", "Research", "Networking"}),
|
|
),
|
|
ui.AutoTableWithToolbarActions(
|
|
table.ColumnPicker(),
|
|
table.CalculatedColumnEditor(),
|
|
exportMenu.TriggerFunc(ui.MenuTriggerProps{Tag: "div"}, func(open bool) *VNode {
|
|
return ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Small: true,
|
|
Icon: "download", Text: "Export"})
|
|
}),
|
|
exportMenu.Content("",
|
|
exportMenu.Item(ui.MenuItemProps{Icon: "file-csv",
|
|
OnClick: func() { table.DownloadCSV("employees") }}, Text("Download CSV")),
|
|
pdfSub.Submenu(ui.SubmenuProps{Trigger: "Download PDF", Icon: "file-pdf"},
|
|
pdfSub.Item(ui.MenuItemProps{
|
|
OnClick: func() { table.DownloadPDF("employees", pdfHeader(false)) }}, Text("Portrait")),
|
|
pdfSub.Item(ui.MenuItemProps{
|
|
OnClick: func() { table.DownloadPDF("employees", pdfHeader(true)) }}, Text("Landscape")),
|
|
),
|
|
ui.MenuDivider(""),
|
|
exportMenu.Item(ui.MenuItemProps{Icon: "print",
|
|
OnClick: func() { table.PrintPDF(pdfHeader(true)) }}, Text("Print")),
|
|
),
|
|
),
|
|
),
|
|
|
|
row("mt-4 flex flex-wrap items-center gap-2",
|
|
ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Small: true, Text: "Find Radia Perlman",
|
|
OnClick: func() { highlight.Set("radia@example.com") }}),
|
|
ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Small: true, Text: "Clear highlight",
|
|
OnClick: func() { highlight.Set("") }}),
|
|
),
|
|
|
|
codeLang("formulas", "syntax", formulaSnippet),
|
|
note("The server renders a skeleton, on purpose",
|
|
"The layout — column order, widths, what is hidden, the calculated columns — lives in the "+
|
|
"browser's localStorage, which the server cannot read. So the server ships a skeleton "+
|
|
"rather than the DEFAULT table: a user who had reordered their columns would otherwise "+
|
|
"watch them rearrange themselves the moment the WebAssembly booted."),
|
|
|
|
apiTable(
|
|
apiRow{"NewAutoTableState", "The controller: the columns, and where to persist the layout."},
|
|
apiRow{".SetRows", "Hand it the data. It filters, sorts and pages from there."},
|
|
apiRow{".RestoreLayout", "Read the saved layout and reveal the table over its skeleton. Once, on the client."},
|
|
apiRow{".FilteredRows / .ExportColumns", "What the user selected, and what they can see — the inputs to any export."},
|
|
apiRow{"ExportCSV / ExportPDF", "Write the bytes, in Go, stdlib only. The PDF writer builds its own xref table."},
|
|
),
|
|
)
|
|
}
|
|
}
|
|
|
|
// ---- overlays ------------------------------------------------------------
|
|
|
|
func overlaysSection(push func(ui.ToastType, string)) func() *VNode {
|
|
tipTop := ui.NewHoverTooltip(ui.PlacementTop, "")
|
|
tipRight := ui.NewHoverTooltip(ui.PlacementRight, "")
|
|
tipFocus := ui.NewFocusTooltip(ui.PlacementBottom, "")
|
|
tipFast := ui.NewTooltip(ui.TooltipProps{Placement: ui.PlacementTop, Delay: -1})
|
|
|
|
pop := ui.NewPopover(ui.PopoverProps{Placement: ui.PlacementBottomStart})
|
|
popEnd := ui.NewPopover(ui.PopoverProps{Placement: ui.PlacementBottomEnd})
|
|
hoverPop := ui.NewHoverPopover(ui.HoverPopoverProps{
|
|
Placement: ui.PlacementTop,
|
|
// The bridge: 300ms of grace for the cursor to cross the gap from trigger onto
|
|
// panel. Without it the panel closes in the dead space between them — which is
|
|
// exactly what happens once a panel is portaled and CSS :hover no longer reaches it.
|
|
HoverCloseDelay: 300,
|
|
})
|
|
|
|
menu := ui.NewMenu(ui.MenuOptions{Placement: ui.PlacementBottomStart})
|
|
sub := ui.NewSubmenu(menu)
|
|
hoverMenu := ui.NewMenu(ui.MenuOptions{Placement: ui.PlacementBottomStart, OpenOnHover: true})
|
|
|
|
modal := ui.NewModal(ui.ModalOptions{Size: ui.ModalMedium})
|
|
nested := ui.NewModal(ui.ModalOptions{Size: ui.ModalSmall})
|
|
confirm := ui.NewModal(ui.ModalOptions{})
|
|
deleted := NewSignal(false)
|
|
|
|
wizardName := NewSignal("")
|
|
wizard := ui.NewWizard(ui.ModalOptions{})
|
|
|
|
return func() *VNode {
|
|
return docSection("overlays", "Overlays",
|
|
prose("Tooltips, popovers, menus and modals — every one measured against the real viewport. "+
|
|
"A floating panel is portaled to document.body, positioned from its trigger's bounding "+
|
|
"box, and flipped or shifted when it would otherwise run off the screen."),
|
|
prose("The position is written to the element with SetStyle, NOT through a signal. A signal "+
|
|
"write re-renders the whole tree, and this runs on every scroll and resize frame; going "+
|
|
"through the vdom would rebuild the page sixty times a second to move one panel four "+
|
|
"pixels."),
|
|
code("webui/floating.go", floatingSnippet),
|
|
|
|
demo("Tooltips — hover, focus, placement, delay",
|
|
row("flex flex-wrap items-center gap-3",
|
|
tipTop.Render(Span(Text("Above — the default")),
|
|
ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Text: "Top"})),
|
|
tipRight.Render(Span(Text("To the right, unless it would run off the edge")),
|
|
ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Text: "Right"})),
|
|
tipFast.Render(Span(Text("No open delay")),
|
|
ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Text: "Instant"})),
|
|
tipFocus.Render(Span(Text("Shown on FOCUS, not hover — tab to the field")),
|
|
ui.FormInput(ui.FormInputProps{Placeholder: "Focus me"})),
|
|
),
|
|
P(Attr("class", "mt-3 text-ss text-ink-muted"),
|
|
Text("Narrow the window and hover \"Right\": it flips to the left, and its arrow follows "+
|
|
"it. A tooltip that only answers to a mouse is a tooltip a keyboard user cannot read.")),
|
|
),
|
|
|
|
demo("Popovers — click, alignment, and a hover bridge",
|
|
row("flex flex-wrap items-center gap-3",
|
|
pop.Trigger(ui.PopoverTriggerProps{},
|
|
ui.Button(ui.ButtonProps{Color: ui.ButtonPrimary, Text: "Click me"})),
|
|
pop.Content(ui.PopoverContentProps{Class: "w-64"},
|
|
P(Attr("class", "text-sm text-ink-soft"),
|
|
Text("Click outside, or press Escape. Only the TOPMOST floating panel closes per press."))),
|
|
|
|
popEnd.Trigger(ui.PopoverTriggerProps{},
|
|
ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Text: "Aligned to my right edge"})),
|
|
popEnd.Content(ui.PopoverContentProps{Class: "w-56"},
|
|
P(Attr("class", "text-sm text-ink-soft"), Text("Placement bottom-end."))),
|
|
|
|
hoverPop.Trigger(ui.PopoverTriggerProps{},
|
|
ui.Button(ui.ButtonProps{Color: ui.ButtonBlue, Outline: true, Text: "Hover, then reach the panel"})),
|
|
hoverPop.Content(ui.PopoverContentProps{Class: "w-64"},
|
|
P(Attr("class", "text-sm text-ink-soft"),
|
|
Text("Move the cursor across the gap and onto this panel — it stays open. Select "+
|
|
"this text to prove it."))),
|
|
),
|
|
),
|
|
|
|
demo("Menus & submenus",
|
|
row("flex flex-wrap items-center gap-3",
|
|
menu.TriggerFunc(ui.MenuTriggerProps{Tag: "div"}, func(open bool) *VNode {
|
|
caret := " ▾"
|
|
if open {
|
|
caret = " ▴"
|
|
}
|
|
return ui.Button(ui.ButtonProps{Color: ui.ButtonWhite, Text: "Actions" + caret})
|
|
}),
|
|
menu.Content("",
|
|
menu.Item(ui.MenuItemProps{Icon: "check", OnClick: func() { push(ui.ToastSuccess, "Profile opened") }},
|
|
Text("Profile")),
|
|
menu.Item(ui.MenuItemProps{OnClick: func() { push(ui.ToastInfo, "Settings opened") }},
|
|
Text("Settings")),
|
|
// Portaled — it used to be clipped by the parent menu's own overflow-y-auto.
|
|
sub.Submenu(ui.SubmenuProps{Trigger: "More", Icon: "ellipsis"},
|
|
sub.Item(ui.MenuItemProps{OnClick: func() { push(ui.ToastInfo, "Archived") }}, Text("Archive")),
|
|
sub.Item(ui.MenuItemProps{OnClick: func() { push(ui.ToastWarning, "Duplicated") }}, Text("Duplicate")),
|
|
),
|
|
ui.MenuDivider(""),
|
|
menu.Item(ui.MenuItemProps{KeepOpen: true, OnClick: func() { push(ui.ToastGeneric, "Menu stayed open") }},
|
|
Text("Stay open (KeepOpen)")),
|
|
menu.Item(ui.MenuItemProps{Icon: "arrow-right-from-bracket",
|
|
OnClick: func() { push(ui.ToastError, "Signed out") }}, Text("Sign out")),
|
|
),
|
|
|
|
hoverMenu.TriggerFunc(ui.MenuTriggerProps{Tag: "div"}, func(bool) *VNode {
|
|
return ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Text: "Opens on hover"})
|
|
}),
|
|
hoverMenu.Content("",
|
|
hoverMenu.Item(ui.MenuItemProps{}, Text("One")),
|
|
hoverMenu.Item(ui.MenuItemProps{}, Text("Two")),
|
|
),
|
|
),
|
|
P(Attr("class", "mt-3 text-ss text-ink-muted"),
|
|
Text("Opening one closes the other — a single-open manager. Submenus are exempt, or a "+
|
|
"submenu would close the very menu it belongs to as it opened. The items raise toasts, "+
|
|
"which is how you can see that an item really does close its own menu.")),
|
|
),
|
|
|
|
demo("Modals — deleted: "+boolStr(deleted.Get()),
|
|
row("flex flex-wrap items-center gap-3",
|
|
ui.Button(ui.ButtonProps{Color: ui.ButtonPrimary, Text: "Open modal", OnClick: modal.Open}),
|
|
ui.Button(ui.ButtonProps{Color: ui.ButtonRed, Outline: true, Text: "Delete something…", OnClick: confirm.Open}),
|
|
ui.Button(ui.ButtonProps{Color: ui.ButtonBlue, Outline: true, Text: "Open wizard", OnClick: wizard.Open}),
|
|
ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Text: "Open imperatively",
|
|
OnClick: func() {
|
|
// No component in the tree owns this one: OpenModal hands content to the
|
|
// shared host rendered once in the layout.
|
|
ui.OpenModal(func() *VNode {
|
|
return ui.ModalContent(ui.ModalContentProps{
|
|
Header: H3(Attr("class", "text-lg font-semibold text-text-heading"), Text("Opened from anywhere")),
|
|
},
|
|
P(Attr("class", "text-ink-soft"),
|
|
Text("This content was rendered by no component — it was handed to ModalHost "+
|
|
"(see AppLayout) by webui.OpenModal. That is what a confirmation raised "+
|
|
"from inside a save handler needs.")),
|
|
)
|
|
}, ui.ModalOptions{Size: ui.ModalSmall})
|
|
}}),
|
|
),
|
|
P(Attr("class", "mt-3 text-ss text-ink-muted"),
|
|
Text("Open the modal, then the nested one inside it, and press Escape twice: modals unwind "+
|
|
"ONE LAYER per press rather than all at once.")),
|
|
|
|
modal.Render(ui.ModalProps{
|
|
Header: H3(Attr("class", "text-lg font-semibold text-text-heading"), Text("A modal")),
|
|
Footer: ui.Button(ui.ButtonProps{Color: ui.ButtonNeutral, Text: "Close", OnClick: modal.Close}),
|
|
},
|
|
P(Attr("class", "text-ink-soft"),
|
|
Text("Portaled to document.body, so no ancestor's overflow:hidden or transform can clip "+
|
|
"it. It fades and scales in over a double requestAnimationFrame — a single frame does "+
|
|
"not give the browser time to commit the initial style, so the transition never runs.")),
|
|
row("mt-4", ui.Button(ui.ButtonProps{Color: ui.ButtonBlue, Outline: true,
|
|
Text: "Open a nested modal", OnClick: nested.Open})),
|
|
),
|
|
nested.Render(ui.ModalProps{
|
|
Header: H3(Attr("class", "text-lg font-semibold text-text-heading"), Text("Nested")),
|
|
},
|
|
P(Attr("class", "text-ink-soft"), Text("Escape closes THIS one first, not the one behind it.")),
|
|
),
|
|
confirm.Confirm(ui.ConfirmModalProps{
|
|
Title: "Delete row",
|
|
Message: "This cannot be undone. (Nothing is actually deleted — this is a docs page.)",
|
|
OnConfirm: func() { deleted.Set(true); push(ui.ToastError, "Row deleted") },
|
|
}),
|
|
wizard.Render(ui.WizardProps{
|
|
Title: "Set up your account",
|
|
FinishText: "Finish",
|
|
OnComplete: func() { push(ui.ToastSuccess, "Wizard complete: "+wizardName.Get()) },
|
|
Steps: []ui.WizardStep{
|
|
{
|
|
Title: "Your name",
|
|
// Each step gets its own context: SetCanContinue gates THIS step's Next
|
|
// button, which one shared bool could not express.
|
|
Content: func(ctx ui.WizardStepContext) *VNode {
|
|
ctx.SetCanContinue(wizardName.Get() != "")
|
|
return row("flex flex-col gap-1",
|
|
ui.FormLabel(ui.FormLabelProps{}, Text("Name (required to continue)")),
|
|
ui.FormInput(ui.FormInputProps{Value: wizardName.Get(), Placeholder: "Ada Lovelace",
|
|
OnInput: func(v string) { wizardName.Set(v) }}),
|
|
)
|
|
},
|
|
},
|
|
{
|
|
Title: "Confirm",
|
|
Content: func(ctx ui.WizardStepContext) *VNode {
|
|
ctx.SetCanContinue(true)
|
|
return P(Attr("class", "text-ink-soft"),
|
|
Text("All set for "+orElse(wizardName.Get(), "nobody")+". Finish to close."))
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
),
|
|
|
|
apiTable(
|
|
apiRow{"NewFloating", "The engine behind every panel: placement, offset, flip, shift, arrow."},
|
|
apiRow{"NewTooltip / NewPopover / NewMenu / NewModal", "Controllers. Build once, outside the render."},
|
|
apiRow{"Standalone", "Exempts a panel from the single-open manager. A submenu needs it, or it closes its own parent."},
|
|
apiRow{"vdom.Portal", "Mounts children at document.body — the escape hatch from an ancestor's overflow:hidden."},
|
|
apiRow{"webui.OpenModal / ModalHost", "Open a modal from code that owns no component. Render the host once, in your layout."},
|
|
),
|
|
)
|
|
}
|
|
}
|
|
|
|
func boolStr(b bool) string {
|
|
if b {
|
|
return "true"
|
|
}
|
|
return "false"
|
|
}
|
|
|
|
// ---- feedback ------------------------------------------------------------
|
|
|
|
func feedbackSection(toaster *ui.Toaster, tour *ui.Tutorial) func() *VNode {
|
|
flash := ui.NewRemoteFlash(2000)
|
|
|
|
return func() *VNode {
|
|
return docSection("feedback", "Toasts & tours",
|
|
prose("Toasts dismiss themselves after five seconds. Watch the bar count down: it is one CSS "+
|
|
"transition, written straight at the element — not a re-render per frame, which is what a "+
|
|
"progress bar driven through a signal would cost you."),
|
|
|
|
demo("Push, dismiss, and a sticky one",
|
|
row("flex flex-wrap items-center gap-2",
|
|
ui.Button(ui.ButtonProps{Color: ui.ButtonGreen, Small: true, Text: "Success",
|
|
OnClick: func() { toaster.Success("Saved.") }}),
|
|
ui.Button(ui.ButtonProps{Color: ui.ButtonRed, Small: true, Text: "Error",
|
|
OnClick: func() { toaster.Error("Something went wrong.") }}),
|
|
ui.Button(ui.ButtonProps{Color: ui.ButtonBlue, Small: true, Text: "Info",
|
|
OnClick: func() { toaster.Info("Just so you know.") }}),
|
|
ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Small: true, Text: "Sticky (no timer)",
|
|
OnClick: func() {
|
|
toaster.Push(ui.Toast{Message: "This one waits for you to dismiss it.",
|
|
Type: ui.ToastWarning, Duration: ui.ToastSticky})
|
|
}}),
|
|
ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Small: true, Text: "Clear all",
|
|
OnClick: toaster.Clear}),
|
|
),
|
|
),
|
|
|
|
demo("The guided tour",
|
|
row("flex flex-wrap items-center gap-3",
|
|
tour.StartButton(0, "", Text("Take the tour")),
|
|
Span(Attr("class", "text-ss text-ink-muted"),
|
|
Text("It dims the page, cuts a hole around each target, and animates the spotlight from "+
|
|
"one to the next. Targets are CSS SELECTORS — the same section ids the sidebar jumps to.")),
|
|
),
|
|
),
|
|
|
|
demo("Remote update flash",
|
|
row("flex flex-wrap items-center gap-3",
|
|
ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Small: true, Text: "Something changed elsewhere",
|
|
OnClick: flash.Fire}),
|
|
ui.RemoteUpdateFlash(flash.Visible()),
|
|
),
|
|
P(Attr("class", "mt-3 text-ss text-ink-muted"),
|
|
Text("A two-second acknowledgement that data you are looking at was changed by somebody "+
|
|
"else. It is not a toast: it belongs next to the thing that moved, not in the corner.")),
|
|
),
|
|
)
|
|
}
|
|
}
|
|
|
|
// ---- navigation ----------------------------------------------------------
|
|
|
|
func navigationSection() func() *VNode {
|
|
tab := NewSignal(0)
|
|
crm := NewSignal(0)
|
|
crmSub := NewSignal(0)
|
|
acc := NewSignal(0)
|
|
multi := NewSignal([]bool{true, false, false})
|
|
side := NewSignal("buttons")
|
|
|
|
return func() *VNode {
|
|
panel := func(s string) *VNode {
|
|
return P(Attr("class", "pt-3 text-sm text-ink-soft"), Text(s))
|
|
}
|
|
|
|
return docSection("navigation", "Tabs & navigation",
|
|
prose("Tabs, an accordion, and a sidebar of jump links. All controlled: the active index goes "+
|
|
"in, the change comes out, and the caller decides what that means — which is what lets a "+
|
|
"tab be driven by the URL, or persisted, without the component knowing anything about it."),
|
|
|
|
demo("Tabs",
|
|
ui.TabGroup(ui.TabGroupProps{
|
|
Items: []ui.TabItem{
|
|
{Title: "Overview", Content: panel("The overview panel.")},
|
|
{Title: "Details", Content: panel("The details panel.")},
|
|
{Title: "Activity", Badge: 3, Content: panel("The activity panel (3 new).")},
|
|
},
|
|
ActiveIndex: tab.Get(),
|
|
OnTabChange: func(i int) { tab.Set(i) },
|
|
}),
|
|
),
|
|
|
|
demo("CRM tabs — the same thing, wearing a different suit",
|
|
row("flex flex-col gap-4",
|
|
ui.CrmTabGroup(ui.CrmTabGroupProps{
|
|
Items: []ui.CrmTabItem{
|
|
{Title: "Contacts", Badge: 12, Content: panel("A contacts list would go here.")},
|
|
{Title: "Deals", Badge: 3, Content: panel("And the deals.")},
|
|
{Title: "Notes", Content: panel("And the notes.")},
|
|
},
|
|
ActiveIndex: crm.Get(),
|
|
OnTabChange: func(i int) { crm.Set(i) },
|
|
}),
|
|
ui.CrmSubTabGroup(ui.CrmTabGroupProps{
|
|
Items: []ui.CrmTabItem{
|
|
{Title: "All", Content: panel("The sub-tab strip: smaller, for nesting inside a tab.")},
|
|
{Title: "Mine", Content: panel("Mine.")},
|
|
},
|
|
ActiveIndex: crmSub.Get(),
|
|
OnTabChange: func(i int) { crmSub.Set(i) },
|
|
}),
|
|
),
|
|
),
|
|
|
|
demo("Accordion — one open at a time, or several",
|
|
row("grid gap-6 lg:grid-cols-2",
|
|
row("flex flex-col gap-2",
|
|
P(Attr("class", "text-ss font-semibold uppercase tracking-widest text-ink-faint"),
|
|
Text("SingleAccordion")),
|
|
ui.SingleAccordion([]ui.AccordionItemData{
|
|
{Title: "What is Kjøl Wasm Web?", Content: panel("kjol's Go→WebAssembly UI engine.")},
|
|
{Title: "Is it isomorphic?", Content: panel("Yes — the same Go server-renders and then hydrates.")},
|
|
{Title: "How is it styled?", Content: panel("Tailwind, compiled by a Go program that reads your Go.")},
|
|
}, acc.Get(), func(i int) { acc.Set(i) }),
|
|
),
|
|
row("flex flex-col gap-2",
|
|
P(Attr("class", "text-ss font-semibold uppercase tracking-widest text-ink-faint"),
|
|
Text("Accordion (several at once)")),
|
|
ui.Accordion([]ui.AccordionItemData{
|
|
{Title: "First", Content: panel("Open me.")},
|
|
{Title: "Second", Content: panel("And me, at the same time.")},
|
|
{Title: "Third", Content: panel("And me.")},
|
|
}, multi.Get(), func(i int) {
|
|
open := append([]bool{}, multi.Get()...)
|
|
open[i] = !open[i]
|
|
multi.Set(open)
|
|
}),
|
|
),
|
|
),
|
|
),
|
|
|
|
demo("Sidebar nav — clicked: "+side.Get(),
|
|
row("max-w-xs",
|
|
ui.SidebarNav([]ui.SidebarNavItem{
|
|
{ID: "buttons", Label: "Buttons", Icon: ui.IconInline("check", 14, "")},
|
|
{ID: "forms", Label: "Forms", Icon: ui.IconInline("pencil", 14, "")},
|
|
{ID: "tables", Label: "Tables", Icon: ui.IconInline("table", 14, ""),
|
|
Children: []ui.SidebarNavItem{
|
|
{ID: "pretty", Label: "PrettyTable"},
|
|
{ID: "auto", Label: "AutoTable"},
|
|
}},
|
|
}, func(id string) { side.Set(id) }, ""),
|
|
),
|
|
P(Attr("class", "mt-3 text-ss text-ink-muted"),
|
|
Text("It reports the id you clicked and nothing else. The sidebar on THIS page is the "+
|
|
"same idea, wired to scroll the section into view.")),
|
|
),
|
|
)
|
|
}
|
|
}
|
|
|
|
// ---- fuzzy search --------------------------------------------------------
|
|
|
|
func searchSection() func() *VNode {
|
|
q := NewSignal("")
|
|
hit := NewSignal("")
|
|
|
|
return func() *VNode {
|
|
options := []string{
|
|
"AutoTable", "Accordion", "Alert", "Badge", "Button", "Calendar", "CellGrid", "Combobox",
|
|
"DatePicker", "FormInput", "Menu", "Modal", "MultiSelect", "Popover", "PrettyTable",
|
|
"SegmentedButtons", "SignaturePad", "TabGroup", "ThemeToggle", "Toast", "ToggleSwitch", "Tooltip",
|
|
}
|
|
|
|
return docSection("search", "Fuzzy search",
|
|
prose("Subsequence matching with a typo tolerance, scored so the best hit sorts first, and "+
|
|
"the matched characters highlighted in the result. \"atbl\" finds AutoTable; so does "+
|
|
"\"autotbale\", which is what you actually typed."),
|
|
|
|
demo("Type into it — picked: "+orElse(hit.Get(), "nothing"),
|
|
row("max-w-md",
|
|
ui.FuzzyMatch(ui.FuzzyMatchProps{
|
|
Options: options,
|
|
Query: q.Get(),
|
|
Placeholder: "Search the kit…",
|
|
MaxResults: 6,
|
|
ShowScores: true,
|
|
OnQueryChange: func(v string) { q.Set(v) },
|
|
OnSelect: func(v string, _ ui.FuzzyRankedItem) { hit.Set(v) },
|
|
}),
|
|
),
|
|
),
|
|
apiTable(
|
|
apiRow{"RankFuzzyMatches", "The scorer, headless. Use it and render the results yourself."},
|
|
apiRow{"FuzzySegments", "Splits a result into matched / unmatched runs, for highlighting."},
|
|
apiRow{"FuzzyMatchTypoTolerant", "One transposition or substitution forgiven."},
|
|
),
|
|
)
|
|
}
|
|
}
|
|
|
|
// ---- charts --------------------------------------------------------------
|
|
|
|
var chartDaysWasm = []string{"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"}
|
|
var pieLabelsWasm = []string{"Direct", "Search", "Social", "Email", "Referral"}
|
|
var regionsWasm = []string{"East", "Central", "Mountain", "Pacific"}
|
|
|
|
// A made-up per-state metric for the choropleth, and a few cities (lat/lng) — Anchorage
|
|
// and Honolulu land on albersUsa's Alaska and Hawaii insets.
|
|
var usSignups = map[string]float64{
|
|
"CA": 4820, "TX": 3910, "NY": 3120, "FL": 2870, "IL": 1740, "PA": 1610, "OH": 1490, "GA": 1450,
|
|
"NC": 1360, "MI": 1280, "WA": 1230, "AZ": 1180, "MA": 1120, "VA": 1090, "CO": 980, "TN": 940,
|
|
"NJ": 910, "OR": 720, "MN": 690, "WI": 610, "MO": 560, "MD": 540, "IN": 520, "NV": 480,
|
|
"UT": 430, "AL": 390, "SC": 360, "KY": 310, "LA": 300, "OK": 280, "CT": 260, "IA": 210,
|
|
"KS": 180, "AK": 140, "HI": 160, "ME": 120, "MT": 90, "WY": 60, "ND": 70, "SD": 80,
|
|
}
|
|
var usCities = []ui.USHeatmapPoint{
|
|
{Label: "Seattle", Lat: 47.6062, Lng: -122.3321, Value: 1230},
|
|
{Label: "San Francisco", Lat: 37.7749, Lng: -122.4194, Value: 2110},
|
|
{Label: "Denver", Lat: 39.7392, Lng: -104.9903, Value: 980},
|
|
{Label: "Chicago", Lat: 41.8781, Lng: -87.6298, Value: 1740},
|
|
{Label: "New York", Lat: 40.7128, Lng: -74.006, Value: 3120},
|
|
{Label: "Miami", Lat: 25.7617, Lng: -80.1918, Value: 1460},
|
|
{Label: "Anchorage", Lat: 61.2181, Lng: -149.9003, Value: 140},
|
|
{Label: "Honolulu", Lat: 21.3069, Lng: -157.8583, Value: 160},
|
|
}
|
|
|
|
func chartsSection() func() *VNode {
|
|
seed := NewSignal(0)
|
|
threeD := NewSignal(false)
|
|
barC, donutC, areaC, lineC := ui.NewChart(), ui.NewChart(), ui.NewChart(), ui.NewChart()
|
|
horizC, stackC := ui.NewChart(), ui.NewChart()
|
|
heat := ui.NewUSHeatmap()
|
|
|
|
return func() *VNode {
|
|
sh := seed.Get()
|
|
shuffle := func(base []float64) []float64 {
|
|
out := make([]float64, len(base))
|
|
for i, b := range base {
|
|
if sh == 0 {
|
|
out[i] = b
|
|
continue
|
|
}
|
|
m := (int(b)*7 + sh*13) % 80
|
|
if m < 4 {
|
|
m = 4
|
|
}
|
|
out[i] = float64(m)
|
|
}
|
|
return out
|
|
}
|
|
requests := ui.ChartSeries{Name: "Requests", Data: shuffle([]float64{42, 17, 63, 28, 55, 9, 71})}
|
|
errs := ui.ChartSeries{Name: "Errors", Data: shuffle([]float64{8, 3, 12, 6, 9, 2, 14})}
|
|
pie := ui.ChartSeries{Name: "Traffic", Data: shuffle([]float64{40, 25, 20, 15, 8})}
|
|
threeDLabel := "3D"
|
|
if threeD.Get() {
|
|
threeDLabel = "Flat"
|
|
}
|
|
|
|
return docSection("charts", "Charts",
|
|
prose("webui.Chart draws its own SVG — nice-scale axes, rounded columns, arc slices, a "+
|
|
"pointer crosshair — with no charting library. The geometry is pure Go, so the same shapes "+
|
|
"the Solid kit draws on /js run here in the WebAssembly, against the same theme tokens. Change "+
|
|
"the data and only the marks that moved re-render. A Title captions the plot, and the legend a "+
|
|
"multi-series or pie/donut chart draws is interactive — clicking a key toggles that series or slice."),
|
|
|
|
demo("Bar, donut, smooth area, two lines — one data set, and a 3D toggle",
|
|
row("grid gap-6 lg:grid-cols-12",
|
|
Div(Attr("class", "lg:col-span-7"), barC.Render(ui.ChartProps{Kind: ui.ChartBar, Title: "Requests & errors this week", Labels: chartDaysWasm, Series: []ui.ChartSeries{requests, errs}, Height: 260, ThreeD: threeD.Get()})),
|
|
Div(Attr("class", "lg:col-span-5"), donutC.Render(ui.ChartProps{Kind: ui.ChartDonut, Title: "Traffic by source", Labels: pieLabelsWasm, Series: []ui.ChartSeries{pie}, Height: 260, ThreeD: threeD.Get()})),
|
|
Div(Attr("class", "lg:col-span-7"), areaC.Render(ui.ChartProps{Kind: ui.ChartArea, Title: "Requests, smoothed", Smooth: true, Labels: chartDaysWasm, Series: []ui.ChartSeries{requests}, Height: 220})),
|
|
Div(Attr("class", "lg:col-span-5"), lineC.Render(ui.ChartProps{Kind: ui.ChartLine, Title: "Requests & errors", Labels: chartDaysWasm, Series: []ui.ChartSeries{requests, errs}, Height: 220})),
|
|
),
|
|
row("mt-4 flex items-center gap-3",
|
|
ui.Button(ui.ButtonProps{Color: ui.ButtonPrimary, Small: true, Text: "New data", OnClick: func() { seed.Set(seed.Get() + 1) }}),
|
|
ui.Button(ui.ButtonProps{Color: ui.ButtonNeutral, Small: true, Text: threeDLabel, OnClick: func() { threeD.Set(!threeD.Get()) }}),
|
|
Span(Attr("class", "text-ss text-ink-muted"), Text("Each chart takes a Title; a multi-series chart also draws a legend. Click a legend key (Errors, or a donut slice) to hide it — the scale and marks recompute. 3D extrudes the bars and tilts the donut.")),
|
|
),
|
|
),
|
|
|
|
demo("Horizontal bars, and stacked",
|
|
row("grid gap-6 lg:grid-cols-2",
|
|
horizC.Render(ui.ChartProps{Kind: ui.ChartBar, Horizontal: true, Labels: chartDaysWasm, Series: []ui.ChartSeries{requests, errs}, Height: 260}),
|
|
stackC.Render(ui.ChartProps{Kind: ui.ChartBar, Stacked: true, Labels: regionsWasm, Series: []ui.ChartSeries{
|
|
{Name: "Requests", Data: shuffle([]float64{42, 55, 28, 63})},
|
|
{Name: "Errors", Data: shuffle([]float64{8, 9, 6, 12})},
|
|
{Name: "Retries", Data: shuffle([]float64{5, 7, 3, 9})},
|
|
}, Height: 260}),
|
|
),
|
|
),
|
|
|
|
demo("US heatmap — a value per state, with proportional lat/lng points on top",
|
|
heat.Render(ui.USHeatmapProps{Data: usSignups, Points: usCities, Proportional: true}),
|
|
Span(Attr("class", "mt-3 block text-ss text-ink-muted"),
|
|
Text("webui.USHeatmap shades each state on the choropleth ramp and projects lat/lng points "+
|
|
"with a Go albersUsa port — Anchorage and Honolulu land on the insets. Hover a state or a point.")),
|
|
),
|
|
)
|
|
}
|
|
}
|
|
|
|
// ---- theming -------------------------------------------------------------
|
|
|
|
func themingSection() func() *VNode {
|
|
tokens := []struct{ Swatch, Name, Role string }{
|
|
{"bg-surface", "surface", "the page"},
|
|
{"bg-surface-muted", "surface-muted", "a recessed strip"},
|
|
{"bg-surface-raised", "surface-raised", "a panel, a hover"},
|
|
{"bg-surface-strong", "surface-strong", "a track, a divider fill"},
|
|
{"bg-line", "line", "an ordinary border"},
|
|
{"bg-line-strong", "line-strong", "a border that has to be seen"},
|
|
{"bg-ink", "ink", "body text, headings"},
|
|
{"bg-ink-soft", "ink-soft", "secondary text"},
|
|
{"bg-ink-muted", "ink-muted", "captions, labels"},
|
|
{"bg-ink-faint", "ink-faint", "placeholders, disabled"},
|
|
}
|
|
|
|
return func() *VNode {
|
|
rows := []*VNode{}
|
|
for i, t := range tokens {
|
|
cls := "flex items-center gap-4 px-4 py-2.5"
|
|
if i > 0 {
|
|
cls += " border-t border-line"
|
|
}
|
|
rows = append(rows, Div(Attr("class", cls),
|
|
Span(Attr("class", "h-7 w-7 shrink-0 rounded border border-line-strong "+t.Swatch)),
|
|
El("code", Attr("class", "w-40 shrink-0 font-mono text-[13px] text-ink"), Text(t.Name)),
|
|
Span(Attr("class", "text-sm text-ink-muted"), Text(t.Role)),
|
|
))
|
|
}
|
|
|
|
return docSection("theming", "Theming",
|
|
prose("No component in this kit names a colour. They say bg-surface, text-ink, border-line — "+
|
|
"and what those mean is decided in one place. Dark mode re-points a dozen CSS variables "+
|
|
"and not one component knows it happened."),
|
|
|
|
demo("The switch",
|
|
row("flex flex-wrap items-center gap-4",
|
|
Theme.ThemeToggle(ui.ThemeToggleProps{}),
|
|
Span(Attr("class", "text-sm text-ink-soft"),
|
|
Text("Press it. Every component on this page moves — none of them were told.")),
|
|
),
|
|
),
|
|
|
|
demo("The contract — each swatch is drawn WITH the token it names",
|
|
swatchTable(rows),
|
|
),
|
|
|
|
note("One vocabulary, two kits",
|
|
"The Solid kit uses these exact token names, and both layers of this site read the same "+
|
|
"kjol-theme key — so choose dark here, walk over to Kjøl JS Web, and it is still dark. "+
|
|
"Two front-ends that share no code at all, and one preference."),
|
|
)
|
|
}
|
|
}
|
|
|
|
// ---- the sidebar's jump links --------------------------------------------
|
|
|
|
// navigateAnchor makes a sidebar link scroll to a section instead of loading a page.
|
|
//
|
|
// A plain <a href="#forms"> would work if the reader were already on this page, and
|
|
// would do nothing useful from anywhere else. So: intercept, route first if we are on
|
|
// another page, and scroll once the section actually exists in the DOM — which is what
|
|
// AfterRender is for, and the only moment at which it is true.
|
|
//
|
|
// On the server Navigate is nil, so this does not intercept at all and the anchor stays
|
|
// a real, working link. A crawler and a reader with JavaScript off both get the jump.
|
|
func navigateAnchor(d Deps, base, frag string) Mod {
|
|
return OnEvent(EVENT_CLICK, func(e Event) {
|
|
if d.Navigate == nil {
|
|
return
|
|
}
|
|
e.PreventDefault()
|
|
|
|
if d.Path() != base {
|
|
d.Navigate(base)
|
|
// The section does not exist yet — the page it lives on has not rendered.
|
|
wasmruntime.AfterRender(func() { scrollToSection(frag) })
|
|
return
|
|
}
|
|
// Already here: the element exists NOW, and no render is coming to hang an
|
|
// AfterRender off.
|
|
scrollToSection(frag)
|
|
})
|
|
}
|
|
|
|
func scrollToSection(id string) {
|
|
// QuerySelector always returns a Ref; ScrollIntoView is a no-op on one with no node,
|
|
// so a stale anchor scrolls nowhere rather than panicking.
|
|
wasmruntime.ScrollIntoView(wasmruntime.QuerySelector("#"+id), true, wasmruntime.ScrollBlockStart)
|
|
}
|
|
|
|
// ---- shared helpers ------------------------------------------------------
|
|
|
|
// orElse is a fallback for an empty string.
|
|
func orElse(s, fallback string) string {
|
|
if s == "" {
|
|
return fallback
|
|
}
|
|
return s
|
|
}
|
|
|
|
// 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 is one labelled block of the gallery — a live demo panel, so that what you
|
|
// are looking at is unmistakably the component running rather than a picture of it.
|
|
func kitSection(title string, body ...*VNode) *VNode {
|
|
return demo(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-ink", Text(name)),
|
|
td("text-ink-soft", 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"},
|
|
}
|
|
}
|
|
|
|
const kitSnippet = `// A component is a function taking a props struct.
|
|
ui.Button(ui.ButtonProps{
|
|
Color: ui.ButtonPrimary,
|
|
Icon: "check",
|
|
Text: "Save",
|
|
OnClick: func() { toaster.Success("Saved.") },
|
|
})
|
|
|
|
// Inputs are controlled: the caller owns the state.
|
|
name := NewSignal("")
|
|
|
|
ui.FormInput(ui.FormInputProps{
|
|
Value: name.Get(),
|
|
OnInput: func(v string) { name.Set(v) }, // a write re-renders
|
|
})`
|
|
|
|
const floatingSnippet = `// Built ONCE — it owns refs, timers, and whether it is open.
|
|
pop := ui.NewPopover(ui.PopoverOptions{
|
|
Placement: ui.PlacementBottomStart,
|
|
Offset: 8,
|
|
})
|
|
|
|
// ...and in the render:
|
|
pop.Trigger(ui.PopoverTriggerProps{},
|
|
ui.Button(ui.ButtonProps{Text: "Click me"}),
|
|
)
|
|
pop.Content(ui.PopoverContentProps{Class: "w-64"},
|
|
P(Text("Outside click and Escape close me.")),
|
|
)
|
|
|
|
// The panel is portaled to document.body and positioned imperatively:
|
|
// render invisible -> AfterRender -> measure -> ComputePosition -> SetStyle -> reveal
|
|
// Never through a signal: this runs on every scroll frame.`
|
|
|
|
// swatchTable wraps the token rows. Div takes ...Mod, and a []*VNode is not that — so
|
|
// the conversion happens here rather than being repeated at every call site.
|
|
func swatchTable(rows []*VNode) *VNode {
|
|
mods := []Mod{Attr("class", "overflow-hidden rounded-default border border-line")}
|
|
for _, r := range rows {
|
|
mods = append(mods, r)
|
|
}
|
|
return Div(mods...)
|
|
}
|