Files
kjol/go/webui/forms.go

1441 lines
44 KiB
Go

package webui
import (
"strconv"
"strings"
"kjol/vdom"
"kjol/wasmruntime"
)
// Port of web/kit/Forms.tsx. Reactive accessors collapse to plain values, and
// value/onChange model as a plain `Value string` field + `OnChange func(string)`
// callback. The advanced combobox/multi-select controls in the TSX rely on
// solid-js/web Portals + floating-ui measurement + refs + document listeners,
// none of which exist in the neutral runtime; those are approximated here (see
// the NOTE comments) with static, CSS-positioned structure and signal-free
// open/toggle wiring. Genuinely browser-only controls are omitted (see bottom).
//
// Naming: the TSX exports are all `Form…`-prefixed, so none collide with the
// vdom tag builders Form/Input/Label/Table; they are kept as-is. The exported
// data table US_STATES becomes USStates (Go casing). Unexported helpers/consts
// are `form`-prefixed per the package convention.
// -- shared Tailwind class strings (verbatim from Forms.tsx) -------------------
const formInputBase = "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"
const formInputBaseDark = "bg-dark text-text-on-dark placeholder:text-text-on-dark-faint block w-full border rounded-default shadow-xs text-sm focus:outline-2 focus:outline-offset-1 disabled:opacity-50 disabled:cursor-not-allowed"
const formErrorCls = "block text-red-600 text-xs mt-1"
const formSuccessCls = "block text-green-600 text-xs mt-1"
const formInputGroupCls = "flex flex-row items-stretch w-full text-sm"
const formTriggerBase = "bg-white border border-neutral-300 rounded-default shadow-xs text-sm w-full text-left flex items-center justify-between gap-2 cursor-pointer disabled:bg-neutral-100 disabled:cursor-not-allowed"
const formTriggerBaseDark = "bg-dark-raised border border-border-on-dark text-text-on-dark rounded-default shadow-xs text-sm w-full text-left flex items-center justify-between gap-2 cursor-pointer hover:border-border-on-dark-hover disabled:opacity-50 disabled:cursor-not-allowed"
const formDropdown = "bg-white border border-neutral-300 rounded-default shadow-lg max-h-60 overflow-auto"
const formDropdownDark = "bg-dark-raised border border-border-on-dark rounded-default shadow-lg max-h-60 overflow-auto"
const formDropdownSearchWrap = "sticky top-0 bg-white border-b border-neutral-200 p-2"
const formDropdownSearchWrapDark = "sticky top-0 bg-dark-raised border-b border-border-on-dark p-2"
const formDropdownSearchInput = "w-full bg-white border border-neutral-300 rounded-default shadow-xs text-sm p-1 focus:outline-2 focus:outline-sky-500 focus:outline-offset-1"
const formDropdownSearchInputDark = "w-full bg-dark border border-border-on-dark text-text-on-dark placeholder:text-text-on-dark-faint rounded-default shadow-xs text-sm p-1 focus:outline-2 focus:outline-sky-500 focus:outline-offset-1"
const formDropdownOption = "w-full text-left p-2 text-sm cursor-pointer flex items-center gap-2 bg-transparent border-none hover:bg-neutral-100 disabled:text-neutral-400 disabled:cursor-not-allowed whitespace-nowrap"
const formDropdownOptionDark = "w-full text-left p-2 text-sm cursor-pointer flex items-center gap-2 bg-transparent border-none text-text-on-dark hover:bg-white/5 disabled:text-text-on-dark-faint disabled:cursor-not-allowed whitespace-nowrap"
const formDropdownNoResults = "p-2 text-sm text-neutral-500 text-center"
const formDropdownNoResultsDark = "p-2 text-sm text-text-on-dark-muted text-center"
const formSelectAllWrap = "border-b border-neutral-200"
const formSelectAllBtn = "w-full text-left p-2 text-sm cursor-pointer text-neutral-600 font-medium bg-transparent border-none hover:bg-neutral-100"
// -- shared class builders -----------------------------------------------------
// formControlH is the fixed control height (small variant is shorter).
func formControlH(small bool) string {
if small {
return "h-[30px]"
}
return "h-[38px]"
}
// formFieldBorder picks the border/focus-outline color: error > success > normal.
// error/success are the message strings; non-empty means "present" (truthy).
func formFieldBorder(errMsg, successMsg string, onDark bool) string {
borderNormal := "border-neutral-300 focus:outline-sky-500"
if onDark {
borderNormal = "border-border-on-dark focus:outline-sky-500"
}
switch {
case errMsg != "":
return "border-red-500 focus:outline-red-500"
case successMsg != "":
return "border-green-500 focus:outline-green-500"
default:
return borderNormal
}
}
func formInputCls(small bool, errMsg, successMsg, extra string, onDark bool) string {
base := formInputBase
if onDark {
base = formInputBaseDark
}
pad := "p-2"
if small {
pad = "p-1"
}
return cx(base, formControlH(small), pad, formFieldBorder(errMsg, successMsg, onDark), extra)
}
// formTextareaCls shares the input look but opts out of the fixed control height
// so the textarea can grow with its content.
func formTextareaCls(small bool, errMsg, extra string, onDark bool) string {
base := formInputBase
if onDark {
base = formInputBaseDark
}
pad := "p-2"
if small {
pad = "p-1"
}
return cx(base, pad, formFieldBorder(errMsg, "", onDark), extra)
}
func formPrefixCls(small bool, errMsg string, onDark bool) string {
base := "bg-neutral-100 border shadow-xs border-r-0 flex items-center shrink-0 rounded-l-default"
borderNormal := "border-neutral-300"
if onDark {
base = "bg-dark-raised text-text-on-dark-muted border shadow-xs border-r-0 flex items-center shrink-0 rounded-l-default"
borderNormal = "border-border-on-dark"
}
border := borderNormal
if errMsg != "" {
border = "border-red-500"
}
pad := "p-2"
if small {
pad = "p-1 text-sm"
}
return cx(base, border, pad)
}
func formTriggerCls(small, onDark bool) string {
base := formTriggerBase
if onDark {
base = formTriggerBaseDark
}
pad := "p-2"
if small {
pad = "p-1"
}
return cx(base, formControlH(small), pad)
}
// ComboboxFieldWidth values sizing a combobox/multi-select field.
const (
ComboboxWidthFill = "fill"
ComboboxWidthGrow = "grow"
ComboboxWidthNarrow = "narrow"
ComboboxWidthDefault = "default"
ComboboxWidthWide = "wide"
ComboboxWidthAuto = "auto"
)
var formComboboxWidthCls = map[string]string{
"fill": "w-full min-w-0 max-w-full",
"grow": "w-full min-w-[8rem] max-w-[16rem]",
"narrow": "w-full min-w-[7rem] max-w-[11rem]",
"default": "w-full min-w-[9rem] max-w-[14rem]",
"wide": "w-full min-w-[10rem] max-w-[20rem]",
"auto": "w-auto min-w-[8rem] max-w-full",
}
func formComboboxRootCls(class, fieldWidth string) string {
w := formComboboxWidthCls[fieldWidth]
if w == "" {
w = formComboboxWidthCls["fill"]
}
return cx("relative", w, class)
}
// formValidationSpans renders the trailing error/success message spans.
func formValidationSpans(errMsg, successMsg string) []vdom.Mod {
var out []vdom.Mod
if errMsg != "" {
out = append(out, vdom.Span(vdom.Attr("class", formErrorCls), vdom.Text(errMsg)))
}
if successMsg != "" {
out = append(out, vdom.Span(vdom.Attr("class", formSuccessCls), vdom.Text(successMsg)))
}
return out
}
// -- shared types --------------------------------------------------------------
// FormSelectOption is one <option> / dropdown entry.
type FormSelectOption struct {
Value string
Label string
Disabled bool
}
// FormInputProps configures FormInput and the specialized single-line inputs.
// Value + OnChange/OnInput model the controlled value: OnInput/OnChange receive
// the current target value (e.Value()).
type FormInputProps struct {
Value string
Type string // defaults to "text"
Placeholder string
Autocomplete string
InputMode string
Name string
ID string
Min string
Max string
Step string
Accept string
MaxLength string
Disabled bool
Small bool
OnDark bool
Error string
Success string
Class string
// PasswordManagerIgnore emits the data-*-ignore attributes that tell 1Password,
// LastPass, Bitwarden, ProtonPass, etc. to skip the field.
PasswordManagerIgnore bool
OnInput func(string)
OnChange func(string)
OnBlur func()
OnFocus func()
OnKeyDown func()
}
// formEventMods wires the input/change/blur/focus/keydown handlers.
func formEventMods(p FormInputProps) []vdom.Mod {
var mods []vdom.Mod
if p.OnInput != nil {
h := p.OnInput
mods = append(mods, vdom.OnEvent(vdom.EVENT_INPUT, func(e vdom.Event) { h(e.Value()) }))
}
if p.OnChange != nil {
h := p.OnChange
mods = append(mods, vdom.OnEvent(vdom.EVENT_CHANGE, func(e vdom.Event) { h(e.Value()) }))
}
if p.OnBlur != nil {
mods = append(mods, vdom.On(vdom.EVENT_BLUR, p.OnBlur))
}
if p.OnFocus != nil {
mods = append(mods, vdom.On(vdom.EVENT_FOCUS, p.OnFocus))
}
if p.OnKeyDown != nil {
// NOTE: the Event interface exposes only Value()/PreventDefault(), so the
// pressed key isn't available here; keydown fires but callers can't inspect it.
mods = append(mods, vdom.On(vdom.EVENT_KEYDOWN, p.OnKeyDown))
}
return mods
}
// formCommonInputMods builds the shared <input> attributes/handlers (identity,
// value, placeholder, password-manager opt-out, sizing hints, events).
func formCommonInputMods(p FormInputProps) []vdom.Mod {
mods := []vdom.Mod{
vdom.Attr("type", pick(p.Type, "text")),
vdom.Prop("value", p.Value),
}
if p.Placeholder != "" {
mods = append(mods, vdom.Attr("placeholder", p.Placeholder))
}
ac := p.Autocomplete
if ac == "" && p.PasswordManagerIgnore {
ac = "off"
}
if ac != "" {
mods = append(mods, vdom.Attr("autocomplete", ac))
}
if p.InputMode != "" {
mods = append(mods, vdom.Attr("inputmode", p.InputMode))
}
if p.PasswordManagerIgnore {
mods = append(mods,
vdom.Attr("data-1p-ignore", "true"),
vdom.Attr("data-lpignore", "other"),
vdom.Attr("data-form-type", "true"),
vdom.Attr("data-bwignore", "true"),
vdom.Attr("data-protonpass-ignore", "true"),
)
}
if p.Disabled {
mods = append(mods, vdom.Attr("disabled", "disabled"))
}
if p.MaxLength != "" {
mods = append(mods, vdom.Attr("maxlength", p.MaxLength))
}
if p.Min != "" {
mods = append(mods, vdom.Attr("min", p.Min))
}
if p.Max != "" {
mods = append(mods, vdom.Attr("max", p.Max))
}
if p.Step != "" {
mods = append(mods, vdom.Attr("step", p.Step))
}
if p.Name != "" {
mods = append(mods, vdom.Attr("name", p.Name))
}
if p.ID != "" {
mods = append(mods, vdom.Attr("id", p.ID))
}
mods = append(mods, formEventMods(p)...)
return mods
}
// -- FormInput / FormInputGroup ------------------------------------------------
// FormInput renders a single-line text <input> wrapped in .ui-form, followed by
// error/success message spans.
func FormInput(p FormInputProps) *vdom.VNode {
class := formInputCls(p.Small, p.Error, p.Success, p.Class, p.OnDark)
inputMods := append([]vdom.Mod{vdom.Attr("class", class)}, formCommonInputMods(p)...)
inner := []vdom.Mod{vdom.Attr("class", "ui-form"), vdom.Input(inputMods...)}
inner = append(inner, formValidationSpans(p.Error, p.Success)...)
return vdom.Div(inner...)
}
// FormInputGroup is a FormInput with a leading prefix cell (e.g. a "$" or an
// icon). The input's left corners are squared to butt against the prefix.
func FormInputGroup(p FormInputProps, prefix *vdom.VNode) *vdom.VNode {
pfxCls := formPrefixCls(p.Small, p.Error, p.OnDark)
inputCls := formInputCls(p.Small, p.Error, p.Success, cx("rounded-l-none", p.Class), p.OnDark)
inputMods := append([]vdom.Mod{vdom.Attr("class", inputCls)}, formCommonInputMods(p)...)
pfxMods := []vdom.Mod{vdom.Attr("class", pfxCls)}
if prefix != nil {
pfxMods = append(pfxMods, prefix)
}
group := vdom.Div(vdom.Attr("class", formInputGroupCls),
vdom.Span(pfxMods...),
vdom.Span(vdom.Attr("class", "grow flex"), vdom.Input(inputMods...)),
)
inner := []vdom.Mod{vdom.Attr("class", "ui-form"), group}
inner = append(inner, formValidationSpans(p.Error, p.Success)...)
return vdom.Div(inner...)
}
// -- specialized single-line inputs --------------------------------------------
//
// NOTE: the TSX masks each keystroke by mutating input.value in the oninput
// handler. Here the cleaning runs on the emitted value: OnInput receives the
// sanitized string, so a controlled caller (Value <- signal <- OnInput) shows
// the masked value on the next render — the standard controlled-input flow.
// FormNumberInput restricts input to digits (+ optional "." and leading "-").
func FormNumberInput(p FormInputProps, integer, unsigned bool) *vdom.VNode {
user := p.OnInput
p.Type = "text"
p.InputMode = "decimal"
p.OnInput = func(v string) {
clean := formCleanNumber(v, integer, unsigned)
if user != nil {
user(clean)
}
}
return FormInput(p)
}
// FormCurrencyInput accepts a decimal amount (max 2 fraction digits). Unless
// hideSymbol is set it is prefixed with "$".
func FormCurrencyInput(p FormInputProps, hideSymbol bool) *vdom.VNode {
user := p.OnInput
p.Type = "text"
p.InputMode = "decimal"
p.OnInput = func(v string) {
clean := formCleanCurrency(v)
if user != nil {
user(clean)
}
}
if hideSymbol {
return FormInput(p)
}
return FormInputGroup(p, vdom.Text("$"))
}
// FormPercentInput accepts a decimal value, prefixed with "%".
func FormPercentInput(p FormInputProps) *vdom.VNode {
user := p.OnInput
p.Type = "text"
p.InputMode = "decimal"
p.OnInput = func(v string) {
clean := formCleanPercent(v)
if user != nil {
user(clean)
}
}
return FormInputGroup(p, vdom.Text("%"))
}
// FormPhoneInput formats a US phone number as "(xxx) xxx-xxxx".
func FormPhoneInput(p FormInputProps) *vdom.VNode {
user := p.OnInput
p.Type = "text"
p.InputMode = "numeric"
if p.Placeholder == "" {
p.Placeholder = "(555) 555-5555"
}
p.OnInput = func(v string) {
clean := formCleanPhone(v)
if user != nil {
user(clean)
}
}
return FormInput(p)
}
// FormPhoneInputWithIcon is FormPhoneInput with a leading icon (default "phone").
func FormPhoneInputWithIcon(icon string, p FormInputProps) *vdom.VNode {
user := p.OnInput
p.Type = "text"
p.InputMode = "numeric"
if p.Placeholder == "" {
p.Placeholder = "(555) 555-5555"
}
p.OnInput = func(v string) {
clean := formCleanPhone(v)
if user != nil {
user(clean)
}
}
// NOTE: "phone" is not in the default icon registry; it renders an empty box
// until the app registers it (see webui.RegisterIcon).
return FormInputGroup(p, IconInline(pick(icon, "phone"), 16, ""))
}
// FormEmailInput is a type=email input; showIcon prefixes an envelope icon.
func FormEmailInput(p FormInputProps, showIcon bool) *vdom.VNode {
p.Type = "email"
p.InputMode = "email"
if !showIcon {
return FormInput(p)
}
// NOTE: "envelope" is not in the default icon registry (renders empty box).
return FormInputGroup(p, IconInline("envelope", 16, ""))
}
// FormURLInput is a URL input; showIcon prefixes a globe icon.
func FormURLInput(p FormInputProps, showIcon bool) *vdom.VNode {
p.Type = "text"
p.InputMode = "url"
if !showIcon {
return FormInput(p)
}
// NOTE: "globe" is not in the default icon registry (renders empty box).
return FormInputGroup(p, IconInline("globe", 16, ""))
}
// FormZipCodeInput formats a US ZIP as "12345" or "12345-6789".
func FormZipCodeInput(p FormInputProps) *vdom.VNode {
user := p.OnInput
p.Type = "text"
p.InputMode = "numeric"
if p.Placeholder == "" {
p.Placeholder = "12345"
}
p.OnInput = func(v string) {
clean := formCleanZip(v)
if user != nil {
user(clean)
}
}
return FormInput(p)
}
// FormCarNumberInput accepts up to 2 leading digits then up to 2 letters (e.g. "28A").
func FormCarNumberInput(p FormInputProps) *vdom.VNode {
user := p.OnInput
p.Type = "text"
p.MaxLength = "4"
if p.Placeholder == "" {
p.Placeholder = "e.g. 28"
}
p.OnInput = func(v string) {
clean := formCleanCarNumber(v)
if user != nil {
user(clean)
}
}
return FormInput(p)
}
// -- input-masking helpers (ported from the TSX oninput handlers) --------------
func formKeepChars(s string, keep func(r rune) bool) string {
var b strings.Builder
for _, r := range s {
if keep(r) {
b.WriteRune(r)
}
}
return b.String()
}
func formDigits(s string) string {
return formKeepChars(s, func(r rune) bool { return r >= '0' && r <= '9' })
}
// formSlice is a bounds-safe s[a:b] (JS .slice/.substring semantics).
func formSlice(s string, a, b int) string {
if a < 0 {
a = 0
}
if b > len(s) {
b = len(s)
}
if a > b {
a = b
}
return s[a:b]
}
func formCleanNumber(v string, integer, unsigned bool) string {
v = formKeepChars(v, func(r rune) bool {
if r >= '0' && r <= '9' {
return true
}
if !integer && r == '.' {
return true
}
if !unsigned && r == '-' {
return true
}
return false
})
if !unsigned {
neg := strings.HasPrefix(v, "-")
v = strings.ReplaceAll(v, "-", "")
if neg {
v = "-" + v
}
}
if !integer {
parts := strings.Split(v, ".")
if len(parts) > 2 {
v = parts[0] + "." + strings.Join(parts[1:], "")
}
}
return v
}
func formCleanCurrency(v string) string {
v = formKeepChars(v, func(r rune) bool { return (r >= '0' && r <= '9') || r == '.' })
parts := strings.Split(v, ".")
if len(parts) > 2 {
v = parts[0] + "." + strings.Join(parts[1:], "")
}
if len(parts) == 2 && len(parts[1]) > 2 {
v = parts[0] + "." + parts[1][:2]
}
return v
}
func formCleanPercent(v string) string {
v = formKeepChars(v, func(r rune) bool { return (r >= '0' && r <= '9') || r == '.' })
parts := strings.Split(v, ".")
if len(parts) > 2 {
v = parts[0] + "." + strings.Join(parts[1:], "")
}
return v
}
func formCleanPhone(v string) string {
digits := formDigits(v)
if len(digits) > 10 {
digits = digits[:10]
}
out := ""
if len(digits) > 0 {
out = "(" + formSlice(digits, 0, 3)
}
if len(digits) > 3 {
out += ") " + formSlice(digits, 3, 6)
}
if len(digits) > 6 {
out += "-" + formSlice(digits, 6, 10)
}
return out
}
func formCleanZip(v string) string {
d := formSlice(formDigits(v), 0, 9)
if len(d) <= 5 {
return d
}
return d[:5] + "-" + d[5:]
}
func formCleanCarNumber(v string) string {
raw := formKeepChars(strings.ToUpper(v), func(r rune) bool {
return (r >= '0' && r <= '9') || (r >= 'A' && r <= 'Z')
})
i := 0
for i < len(raw) && i < 2 && raw[i] >= '0' && raw[i] <= '9' {
i++
}
j := i
for j < len(raw) && j < i+2 && raw[j] >= 'A' && raw[j] <= 'Z' {
j++
}
return raw[:i] + raw[i:j]
}
// -- FormTextarea --------------------------------------------------------------
// FormTextareaProps configures FormTextarea.
type FormTextareaProps struct {
Value string
Placeholder string
Rows string
Name string
ID string
Disabled bool
Small bool
OnDark bool
Error string
Class string
OnInput func(string)
OnChange func(string)
OnBlur func()
}
// FormTextarea renders a multi-line <textarea> (grows with content). Only the
// error message is shown (matching the TSX).
func FormTextarea(p FormTextareaProps) *vdom.VNode {
class := formTextareaCls(p.Small, p.Error, p.Class, p.OnDark)
mods := []vdom.Mod{
vdom.Attr("class", class),
vdom.Prop("value", p.Value),
}
if p.Placeholder != "" {
mods = append(mods, vdom.Attr("placeholder", p.Placeholder))
}
if p.Rows != "" {
mods = append(mods, vdom.Attr("rows", p.Rows))
}
if p.Disabled {
mods = append(mods, vdom.Attr("disabled", "disabled"))
}
if p.Name != "" {
mods = append(mods, vdom.Attr("name", p.Name))
}
if p.ID != "" {
mods = append(mods, vdom.Attr("id", p.ID))
}
if p.OnInput != nil {
h := p.OnInput
mods = append(mods, vdom.OnEvent(vdom.EVENT_INPUT, func(e vdom.Event) { h(e.Value()) }))
}
if p.OnChange != nil {
h := p.OnChange
mods = append(mods, vdom.OnEvent(vdom.EVENT_CHANGE, func(e vdom.Event) { h(e.Value()) }))
}
if p.OnBlur != nil {
mods = append(mods, vdom.On(vdom.EVENT_BLUR, p.OnBlur))
}
inner := []vdom.Mod{vdom.Attr("class", "ui-form"), vdom.Textarea(mods...)}
inner = append(inner, formValidationSpans(p.Error, "")...)
return vdom.Div(inner...)
}
// -- FormLabel -----------------------------------------------------------------
// FormLabelProps configures FormLabel.
type FormLabelProps struct {
Inline bool
For string
Title string
Class string
OnDark bool
}
// FormLabel renders a form <label>.
func FormLabel(p FormLabelProps, children ...*vdom.VNode) *vdom.VNode {
display := "block"
if p.Inline {
display = "inline"
}
color := "text-neutral-700"
if p.OnDark {
color = "text-text-on-dark"
}
mods := []vdom.Mod{vdom.Attr("class", cx(display, color, "text-sm font-medium mb-2", p.Class))}
if p.For != "" {
mods = append(mods, vdom.Attr("for", p.For))
}
if p.Title != "" {
mods = append(mods, vdom.Attr("title", p.Title))
}
return vdom.Label(kids(mods, children)...)
}
// -- FormFileInput / FormSpacer / FormFieldset ---------------------------------
// FormFileInput renders a styled type=file input.
func FormFileInput(p FormInputProps) *vdom.VNode {
filePad := "file:py-[3px] file:px-4"
if p.Small {
filePad = "file:py-[2px] file:px-3"
}
class := cx(formInputBase,
"p-1 border-neutral-300 focus:outline-sky-500 cursor-pointer",
"file:ml-1 file:mr-2 file:bg-neutral-100 file:border file:border-neutral-300 file:rounded-default file:shadow-xs file:text-sm file:cursor-pointer file:hover:bg-neutral-200",
filePad, p.Class)
mods := []vdom.Mod{
vdom.Attr("type", "file"),
vdom.Attr("class", class),
}
if p.Disabled {
mods = append(mods, vdom.Attr("disabled", "disabled"))
}
if p.Name != "" {
mods = append(mods, vdom.Attr("name", p.Name))
}
if p.ID != "" {
mods = append(mods, vdom.Attr("id", p.ID))
}
if p.Accept != "" {
mods = append(mods, vdom.Attr("accept", p.Accept))
}
if p.OnChange != nil {
h := p.OnChange
mods = append(mods, vdom.OnEvent(vdom.EVENT_CHANGE, func(e vdom.Event) { h(e.Value()) }))
}
return vdom.Div(vdom.Attr("class", "ui-form"), vdom.Input(mods...))
}
// FormSpacer is vertical spacing between form controls.
func FormSpacer() *vdom.VNode { return vdom.Div(vdom.Attr("class", "mb-3")) }
// FormFieldset wraps children in a bordered <fieldset> with an optional legend.
func FormFieldset(legend, class string, children ...*vdom.VNode) *vdom.VNode {
mods := []vdom.Mod{
vdom.Attr("class", cx("border border-neutral-300 rounded-default py-3 px-4", class)),
vdom.Legend(vdom.Attr("class", "px-2 text-sm font-medium text-neutral-600"), vdom.Text(legend)),
}
mods = kids(mods, children)
return vdom.Fieldset(mods...)
}
// -- FormSelect (native <select>) ----------------------------------------------
// FormSelectProps configures FormSelect. The current Value is applied as the
// select's live `value` property (matching the TSX's deferred-effect approach).
type FormSelectProps struct {
Value string
Small bool
Class string
OnDark bool
Disabled bool
Name string
ID string
OnChange func(string)
}
// FormOption builds an <option> for use as a FormSelect child.
func FormOption(value, label string, disabled bool) *vdom.VNode {
mods := []vdom.Mod{vdom.Attr("value", value)}
if disabled {
mods = append(mods, vdom.Attr("disabled", "disabled"))
}
mods = append(mods, vdom.Text(label))
return vdom.Option(mods...)
}
// FormSelect renders a native <select> (its <option> children are passed in).
func FormSelect(p FormSelectProps, children ...*vdom.VNode) *vdom.VNode {
class := formInputCls(p.Small, "", "", p.Class, p.OnDark)
mods := []vdom.Mod{
vdom.Attr("class", class),
vdom.Prop("value", p.Value),
}
if p.Disabled {
mods = append(mods, vdom.Attr("disabled", "disabled"))
}
if p.Name != "" {
mods = append(mods, vdom.Attr("name", p.Name))
}
if p.ID != "" {
mods = append(mods, vdom.Attr("id", p.ID))
}
if p.OnChange != nil {
h := p.OnChange
mods = append(mods, vdom.OnEvent(vdom.EVENT_CHANGE, func(e vdom.Event) { h(e.Value()) }))
}
mods = kids(mods, children)
return vdom.Div(vdom.Attr("class", "ui-form"), vdom.Select(mods...))
}
// FormStateSelector is a native select pre-populated with USStates.
func FormStateSelector(p FormSelectProps) *vdom.VNode {
opts := make([]*vdom.VNode, 0, len(USStates))
for _, s := range USStates {
opts = append(opts, FormOption(s.Value, s.Label, s.Disabled))
}
return FormSelect(p, opts...)
}
// FormTimezoneSelector is a native select of common US timezones.
func FormTimezoneSelector(p FormSelectProps) *vdom.VNode {
opts := make([]*vdom.VNode, 0, len(formTimezones))
for _, t := range formTimezones {
opts = append(opts, FormOption(t.Value, t.Label, t.Disabled))
}
return FormSelect(p, opts...)
}
// -- combobox / multi-select ---------------------------------------------------
//
// Both are built on the Floating controller, which is where their close behaviour
// comes from: an outside mousedown, and Escape closing the topmost panel only. The
// previous port took `Open bool` + `OnToggle func()` from the caller and had NEITHER
// — a dropdown, once open, stayed open until you clicked its trigger again. It also
// rendered its panel in-flow, so it was clipped by any scrolling ancestor, and its
// search box was decorative: it filtered nothing.
//
// # Where a multi-select differs from a combobox, deliberately
//
// A combobox picks ONE thing, so choosing closes it. A multi-select picks SEVERAL, so
// choosing does NOT — you are mid-selection, and closing the panel under you after
// each tick would make it unusable. That is why both are controllers over the same
// Floating but only one of them hides on select.
//
// Everything else they share: the panel is portaled to document.body (so it escapes
// overflow), positioned by measurement, flipped when it will not fit, and closed by an
// outside click or Escape.
// dropdown is the shared half of Combobox and MultiSelect.
type dropdown struct {
f *Floating
search *vdom.Signal[string]
searchRef *vdom.Ref
// active is the keyboard-highlighted option, as an index into the FILTERED list.
// -1 means nothing is highlighted.
active *vdom.Signal[int]
}
// DropdownOptions configures NewCombobox / NewMultiSelect.
type DropdownOptions struct {
// Placement defaults to bottom-start. The panel flips above the field when there
// is not room below.
Placement string
OnOpenChange func(bool)
}
func newDropdown(o DropdownOptions, closeOnSelect bool) *dropdown {
d := &dropdown{
search: vdom.NewSignal(""),
searchRef: vdom.NewRef(),
active: vdom.NewSignal(-1),
}
d.f = NewFloating(FloatingOptions{
Placement: pick(o.Placement, PlacementBottomStart),
Offset: 4,
// A long option list near the bottom of the page scrolls inside its own box
// rather than running off the screen.
ConstrainToViewport: true,
// Standalone: these open INSIDE other floatings (a filter popover, the calc
// editor's form). Without it the single-open manager would read the dropdown as
// a rival panel and close the very popover it belongs to.
Standalone: true,
OnOpenChange: func(open bool) {
// Closing abandons the query. Reopening to a stale filter — showing three of
// twenty options for no visible reason — is worse than retyping.
if !open {
d.search.Set("")
d.active.Set(-1)
}
if o.OnOpenChange != nil {
o.OnOpenChange(open)
}
},
})
_ = closeOnSelect
return d
}
// IsOpen / Open / Close / Toggle drive the panel.
func (d *dropdown) IsOpen() bool { return d.f.IsOpen() }
func (d *dropdown) Open() { d.f.Show() }
func (d *dropdown) Close() { d.f.Hide() }
func (d *dropdown) Toggle() { d.f.Toggle() }
// Dispose removes the panel's listeners. Call it if the component owning this
// dropdown goes away while the panel might still be open.
func (d *dropdown) Dispose() { d.f.Dispose() }
// filter narrows the options by the search query — the box actually filters now.
// Matching is case-insensitive on the label, which is what the user can see.
func (d *dropdown) filter(opts []FormSelectOption) []FormSelectOption {
q := strings.TrimSpace(strings.ToLower(d.search.Get()))
if q == "" {
return opts
}
out := make([]FormSelectOption, 0, len(opts))
for _, o := range opts {
if strings.Contains(strings.ToLower(o.Label), q) {
out = append(out, o)
}
}
return out
}
// searchBox is the filter field at the top of the panel. It is focused when the panel
// opens, so a searchable dropdown can be driven entirely from the keyboard.
func (d *dropdown) searchBox(placeholder string, onDark bool, count int) *vdom.VNode {
wrapCls, inputCls := formDropdownSearchWrap, formDropdownSearchInput
if onDark {
wrapCls, inputCls = formDropdownSearchWrapDark, formDropdownSearchInputDark
}
// Focus it once the panel is in the DOM. Scheduled rather than called: the input
// does not exist yet at the moment the signal that opens the panel is written.
wasmruntime.AfterRender(func() { wasmruntime.Focus(d.searchRef) })
return vdom.Div(vdom.Attr("class", wrapCls),
vdom.Input(
vdom.WithRef(d.searchRef),
vdom.Attr("type", "text"),
vdom.Attr("class", inputCls),
vdom.Attr("placeholder", pick(placeholder, "Search...")),
vdom.Attr("spellcheck", "false"),
vdom.Prop("value", d.search.Get()),
vdom.OnEvent(vdom.EVENT_INPUT, func(e vdom.Event) {
d.search.Set(e.Value())
d.active.Set(-1) // the old highlight indexed a different list
}),
vdom.OnEvent(vdom.EVENT_KEYDOWN, func(e vdom.Event) { d.onSearchKey(e, count) }),
),
)
}
// onSearchKey is arrow-key navigation over the filtered list. Escape is NOT handled
// here: Floating already closes the topmost panel on Escape, and handling it twice
// would close a dropdown and the popover around it with one press.
func (d *dropdown) onSearchKey(e vdom.Event, count int) {
switch e.Key() {
case vdom.KEY_ARROW_DOWN:
e.PreventDefault()
if count > 0 {
d.active.Set((d.active.Get() + 1) % count)
}
case vdom.KEY_ARROW_UP:
e.PreventDefault()
if count > 0 {
next := d.active.Get() - 1
if next < 0 {
next = count - 1
}
d.active.Set(next)
}
}
}
// optionButton is one row of the panel.
func (d *dropdown) optionButton(opt FormSelectOption, idx int, selected, onDark, small bool, onClick func()) *vdom.VNode {
cls := formDropdownOption
if onDark {
cls = formDropdownOptionDark
}
if small {
cls = cx(cls, "py-1.5 px-2")
}
if idx == d.active.Get() {
cls = cx(cls, "bg-neutral-100")
}
mods := []vdom.Mod{
vdom.Attr("type", "button"),
vdom.Attr("class", cls),
vdom.Attr("role", "option"),
vdom.Attr("aria-selected", strconv.FormatBool(selected)),
}
if opt.Disabled {
mods = append(mods, vdom.Attr("disabled", "disabled"))
} else if onClick != nil {
mods = append(mods, vdom.On(vdom.EVENT_CLICK, onClick))
}
return vdom.Button(append(mods, vdom.Text(opt.Label))...)
}
func (d *dropdown) noResults(onDark bool) *vdom.VNode {
cls := formDropdownNoResults
if onDark {
cls = formDropdownNoResultsDark
}
return vdom.Div(vdom.Attr("class", cls), vdom.Text("No options found"))
}
// panel wraps the dropdown's rows in the floating panel. Width is matched to the
// field so the options line up under it.
func (d *dropdown) panel(onDark bool, children ...*vdom.VNode) *vdom.VNode {
cls := formDropdown
if onDark {
cls = formDropdownDark
}
// The exact width is written by matchFieldWidth once the field has been measured;
// min-w-48 is only a floor, so a very narrow field still gets a readable list.
return d.f.Panel(FloatingPanelProps{
Role: "listbox",
Class: cx("min-w-48", cls),
}, children...)
}
// matchFieldWidth sizes the panel to the field it hangs off. Done imperatively, from
// a measurement, because the two are no longer DOM relatives: the panel is portaled to
// document.body, so it cannot simply be `w-full`.
func (d *dropdown) matchFieldWidth() {
wasmruntime.AfterRender(func() {
w := wasmruntime.Measure(d.f.triggerRef).Width
if w > 0 {
wasmruntime.SetStyle(d.f.panelRef, "width", px(w))
}
})
}
// ---- Combobox: pick one ----
// Combobox is a single-select dropdown. Choosing an option closes it.
//
// Create it once, alongside your signals — never inside a render function, which
// would rebuild its refs and open state every frame:
//
// team := webui.NewCombobox(webui.DropdownOptions{})
// …
// team.Render(webui.FormComboboxProps{Options: opts, Value: v.Get(), OnChange: v.Set})
type Combobox struct{ *dropdown }
// NewCombobox creates a single-select dropdown.
func NewCombobox(o DropdownOptions) *Combobox {
return &Combobox{dropdown: newDropdown(o, true)}
}
// FormComboboxProps configures Combobox.Render.
type FormComboboxProps struct {
Options []FormSelectOption
Value string
OnChange func(string)
Placeholder string
Searchable bool
SearchPlaceholder string
Small bool
Class string
FieldWidth string
OnDark bool
MaxDisplayLength int
Disabled bool
}
// Render draws the field and its panel.
func (c *Combobox) Render(p FormComboboxProps) *vdom.VNode {
var selected *FormSelectOption
for i := range p.Options {
if p.Options[i].Value == p.Value {
selected = &p.Options[i]
break
}
}
display := pick(p.Placeholder, "Select an option")
placeholderCls := "text-neutral-500"
if p.OnDark {
placeholderCls = "text-text-on-dark-muted"
}
if selected != nil {
display = truncateRunes(selected.Label, p.MaxDisplayLength)
placeholderCls = ""
}
chevronCls := "text-neutral-400"
if p.OnDark {
chevronCls = "text-text-on-dark-muted"
}
chevron := "chevron-down"
if c.IsOpen() {
chevron = "chevron-up"
}
trigger := c.f.Trigger(FloatingTriggerProps{
Class: formTriggerCls(p.Small, p.OnDark),
AriaHasPopup: "listbox",
},
vdom.Span(vdom.Attr("class", cx("min-w-0 truncate", placeholderCls)), vdom.Text(display)),
IconInline(chevron, 16, chevronCls),
)
if p.Disabled {
trigger.Attrs["disabled"] = "disabled"
}
filtered := c.filter(p.Options)
rows := make([]*vdom.VNode, 0, len(filtered)+1)
if p.Searchable && c.IsOpen() {
rows = append(rows, c.searchBox(p.SearchPlaceholder, p.OnDark, len(filtered)))
}
if len(filtered) == 0 {
rows = append(rows, c.noResults(p.OnDark))
}
for i := range filtered {
opt := filtered[i]
rows = append(rows, c.optionButton(opt, i, opt.Value == p.Value, p.OnDark, p.Small, func() {
if p.OnChange != nil {
p.OnChange(opt.Value)
}
c.Close() // one choice, so we are done
}))
}
if c.IsOpen() {
c.matchFieldWidth()
}
return vdom.Div(
vdom.Attr("class", formComboboxRootCls(p.Class, p.FieldWidth)),
trigger,
c.panel(p.OnDark, rows...),
)
}
// NewSearchableSelect is a Combobox with the search box on — the old
// FormSearchableSelect.
func NewSearchableSelect(o DropdownOptions) *Combobox { return NewCombobox(o) }
// ---- MultiSelect: pick several ----
// MultiSelect is a multi-select dropdown with removable tags and an optional
// select-all row.
//
// Unlike a Combobox it does NOT close when an option is chosen: you are mid-selection,
// and closing the panel after each tick would make it useless. It closes on an outside
// click, on Escape, or when its trigger is clicked again.
//
// Create it once, alongside your signals — never inside a render function.
type MultiSelect struct{ *dropdown }
// NewMultiSelect creates a multi-select dropdown.
func NewMultiSelect(o DropdownOptions) *MultiSelect {
return &MultiSelect{dropdown: newDropdown(o, false)}
}
// FormMultiSelectProps configures MultiSelect.Render.
type FormMultiSelectProps struct {
Options []FormSelectOption
Value []string
OnChange func([]string)
Placeholder string
Searchable bool
SearchPlaceholder string
ShowSelectAll bool
MaxTagsBeforeCollapse int
Disabled bool
Small bool
Class string
FieldWidth string
}
// Render draws the field and its panel.
func (m *MultiSelect) Render(p FormMultiSelectProps) *vdom.VNode {
maxTags := p.MaxTagsBeforeCollapse
if maxTags == 0 {
maxTags = 3
}
var selected []FormSelectOption
for _, opt := range p.Options {
if formContainsStr(p.Value, opt.Value) {
selected = append(selected, opt)
}
}
// The trigger's face: a placeholder, tags, or "N selected" once there are too many
// to show without the field growing unboundedly.
var face *vdom.VNode
switch {
case len(selected) == 0:
face = vdom.Span(vdom.Attr("class", "text-neutral-500 truncate"),
vdom.Text(pick(p.Placeholder, "Select options")))
case len(selected) > maxTags:
face = vdom.Span(vdom.Attr("class", "truncate"),
vdom.Text(strconv.Itoa(len(selected))+" selected"))
default:
tags := []vdom.Mod{vdom.Attr("class", "flex flex-wrap items-center gap-1 min-w-0")}
for _, opt := range selected {
ov := opt.Value
remove := []vdom.Mod{
vdom.Attr("type", "button"),
vdom.Attr("class", "bg-transparent border-none p-0 cursor-pointer flex items-center hover:text-neutral-900"),
vdom.Attr("aria-label", "Remove "+opt.Label),
IconInline("xmark", 10, ""),
}
if p.OnChange != nil {
oc, cur := p.OnChange, p.Value
remove = append(remove, vdom.OnEvent(vdom.EVENT_CLICK, func(e vdom.Event) {
// The tag sits INSIDE the trigger, so without this the click would
// bubble up and toggle the panel open as it removed the tag.
e.StopPropagation()
oc(formRemoveStr(cur, ov))
}))
}
tags = append(tags, vdom.Span(vdom.Attr("class", formMultiSelectTag),
vdom.Text(opt.Label),
vdom.Button(remove...),
))
}
face = vdom.Div(tags...)
}
pad := "p-2"
if p.Small {
pad = "p-1"
}
chevron := "chevron-down"
if m.IsOpen() {
chevron = "chevron-up"
}
trigger := m.f.Trigger(FloatingTriggerProps{
Class: cx(formTriggerBase, "overflow-hidden", formControlH(p.Small), pad),
AriaHasPopup: "listbox",
},
vdom.Div(vdom.Attr("class", "flex-1 overflow-hidden flex items-center min-w-0"), face),
IconInline(chevron, 16, "text-neutral-400"),
)
if p.Disabled {
trigger.Attrs["disabled"] = "disabled"
}
filtered := m.filter(p.Options)
rows := make([]*vdom.VNode, 0, len(filtered)+2)
if p.Searchable && m.IsOpen() {
rows = append(rows, m.searchBox(p.SearchPlaceholder, false, len(filtered)))
}
// Select-all acts on what is VISIBLE. Selecting all of a filtered list is what the
// user is looking at and asked for; quietly selecting the hidden ones too would be
// a nasty surprise.
if p.ShowSelectAll && len(filtered) > 0 {
all := formAllSelected(filtered, p.Value)
label := "Select All"
if all {
label = "Deselect All"
}
mods := []vdom.Mod{vdom.Attr("type", "button"), vdom.Attr("class", formSelectAllBtn), vdom.Text(label)}
if p.OnChange != nil {
oc, cur, opts, allNow := p.OnChange, p.Value, filtered, all
mods = append(mods, vdom.On(vdom.EVENT_CLICK, func() {
if allNow {
next := cur
for _, o := range opts {
next = formRemoveStr(next, o.Value)
}
oc(next)
return
}
oc(formSelectAllValues(opts, cur))
}))
}
rows = append(rows, vdom.Button(mods...))
}
if len(filtered) == 0 {
rows = append(rows, m.noResults(false))
}
for i := range filtered {
opt := filtered[i]
on := formContainsStr(p.Value, opt.Value)
rows = append(rows, m.optionButton(opt, i, on, false, p.Small, func() {
if p.OnChange != nil {
p.OnChange(formToggleStr(p.Value, opt.Value))
}
// Deliberately NOT closing: this is a MULTI-select, and the user is very
// likely about to tick another one.
}))
}
if m.IsOpen() {
m.matchFieldWidth()
}
return vdom.Div(
vdom.Attr("class", formComboboxRootCls(p.Class, p.FieldWidth)),
trigger,
m.panel(false, rows...),
)
}
const formMultiSelectTag = "inline-flex items-center gap-1 rounded-default bg-neutral-100 px-1.5 py-0.5 text-xs text-neutral-700 max-w-full"
func truncateRunes(s string, max int) string {
if max <= 0 {
return s
}
r := []rune(s)
if len(r) <= max {
return s
}
return strings.TrimRight(string(r[:max]), " ") + "…"
}
// -- []string selection helpers (for FormMultiSelect) --------------------------
func formContainsStr(list []string, v string) bool {
for _, x := range list {
if x == v {
return true
}
}
return false
}
func formRemoveStr(list []string, v string) []string {
out := []string{}
for _, x := range list {
if x != v {
out = append(out, x)
}
}
return out
}
func formToggleStr(list []string, v string) []string {
if formContainsStr(list, v) {
return formRemoveStr(list, v)
}
return append(append([]string{}, list...), v)
}
// formAllSelected reports whether every non-disabled option is selected (an
// empty option set is vacuously true, matching Array.prototype.every).
func formAllSelected(opts []FormSelectOption, val []string) bool {
for _, o := range opts {
if o.Disabled {
continue
}
if !formContainsStr(val, o.Value) {
return false
}
}
return true
}
func formSelectAllValues(opts []FormSelectOption, val []string) []string {
out := append([]string{}, val...)
for _, o := range opts {
if o.Disabled {
continue
}
if !formContainsStr(out, o.Value) {
out = append(out, o.Value)
}
}
return out
}
func formDeselectAll(opts []FormSelectOption, val []string) []string {
remove := map[string]bool{}
for _, o := range opts {
remove[o.Value] = true
}
out := []string{}
for _, v := range val {
if !remove[v] {
out = append(out, v)
}
}
return out
}
// -- reference data ------------------------------------------------------------
// USStates is the US state option list (ported from US_STATES; renamed to Go
// casing). The leading entry is a disabled placeholder.
var USStates = []FormSelectOption{
{Value: "", Label: "Please select a state", Disabled: true},
{Value: "AL", Label: "Alabama"},
{Value: "AK", Label: "Alaska"},
{Value: "AZ", Label: "Arizona"},
{Value: "AR", Label: "Arkansas"},
{Value: "CA", Label: "California"},
{Value: "CO", Label: "Colorado"},
{Value: "CT", Label: "Connecticut"},
{Value: "DE", Label: "Delaware"},
{Value: "DC", Label: "District of Columbia"},
{Value: "FL", Label: "Florida"},
{Value: "GA", Label: "Georgia"},
{Value: "HI", Label: "Hawaii"},
{Value: "ID", Label: "Idaho"},
{Value: "IL", Label: "Illinois"},
{Value: "IN", Label: "Indiana"},
{Value: "IA", Label: "Iowa"},
{Value: "KS", Label: "Kansas"},
{Value: "KY", Label: "Kentucky"},
{Value: "LA", Label: "Louisiana"},
{Value: "ME", Label: "Maine"},
{Value: "MD", Label: "Maryland"},
{Value: "MA", Label: "Massachusetts"},
{Value: "MI", Label: "Michigan"},
{Value: "MN", Label: "Minnesota"},
{Value: "MS", Label: "Mississippi"},
{Value: "MO", Label: "Missouri"},
{Value: "MT", Label: "Montana"},
{Value: "NE", Label: "Nebraska"},
{Value: "NV", Label: "Nevada"},
{Value: "NH", Label: "New Hampshire"},
{Value: "NJ", Label: "New Jersey"},
{Value: "NM", Label: "New Mexico"},
{Value: "NY", Label: "New York"},
{Value: "NC", Label: "North Carolina"},
{Value: "ND", Label: "North Dakota"},
{Value: "OH", Label: "Ohio"},
{Value: "OK", Label: "Oklahoma"},
{Value: "OR", Label: "Oregon"},
{Value: "PA", Label: "Pennsylvania"},
{Value: "PR", Label: "Puerto Rico"},
{Value: "RI", Label: "Rhode Island"},
{Value: "SC", Label: "South Carolina"},
{Value: "SD", Label: "South Dakota"},
{Value: "TN", Label: "Tennessee"},
{Value: "TX", Label: "Texas"},
{Value: "UT", Label: "Utah"},
{Value: "VT", Label: "Vermont"},
{Value: "VI", Label: "Virgin Islands"},
{Value: "VA", Label: "Virginia"},
{Value: "WA", Label: "Washington"},
{Value: "WV", Label: "West Virginia"},
{Value: "WI", Label: "Wisconsin"},
{Value: "WY", Label: "Wyoming"},
}
var formTimezones = []FormSelectOption{
{Value: "", Label: "Please select a timezone", Disabled: true},
{Value: "America/New_York", Label: "Eastern"},
{Value: "America/Chicago", Label: "Central"},
{Value: "America/Denver", Label: "Mountain"},
{Value: "America/Los_Angeles", Label: "Pacific"},
{Value: "America/Anchorage", Label: "Alaska"},
{Value: "Pacific/Honolulu", Label: "Hawaii"},
}
// -- intentionally omitted (no neutral-runtime equivalent) ---------------------
//
// NOTE: the following TSX exports are omitted. They depend on capabilities the
// neutral vdom runtime does not provide, and have no meaningful static form:
//
// - FormSignaturePad — freehand drawing on a <canvas> 2D context with
// mouse/touch tracking and SVG serialization.
// - FormAsyncCombobox — debounced async option loading via Promises/timers.
// - FormMultiSelectTrigger — a custom-trigger multi-select popover (Portal-free
// but still driven by document listeners + refs); its
// selection model is already covered by FormMultiSelect.
// - handleTaxIdInput / handleRateInput — standalone oninput handlers that mutate
// a DOM element in place; with no component consumer in
// this file and no DOM handle in handlers, they don't
// port. (Their masking logic mirrors formClean* above.)