Files
kjol/go/webui/forms.go

1263 lines
39 KiB
Go

package webui
import (
"strconv"
"strings"
"kjol/vdom"
)
// 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 (approximated) ------------------------------------
//
// NOTE: FormCombobox, FormSearchableSelect and FormMultiSelect in the TSX open a
// solid-js Portal, measure the trigger with getBoundingClientRect + a
// requestAnimationFrame position tracker (floating-ui style), focus-trap the
// search box, filter options against the query, track a keyboard-highlighted
// index, and close on document mousedown. None of that has a neutral-runtime
// equivalent, so these ports:
// - take `Open bool` + `OnToggle func()` instead of an internal open signal,
// - render the dropdown inline, positioned with `absolute top-full` utilities
// rather than fixed computed coordinates (no Portal),
// - render ALL options (the search box is decorative — client-side filtering,
// keyboard nav, and highlight tracking are dropped),
// - keep selection fully functional through Value + OnChange.
// FormComboboxProps configures FormCombobox / FormSearchableSelect.
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
// Open/OnToggle replace the internal open signal (see NOTE above).
Open bool
OnToggle func()
}
// FormCombobox is a single-select dropdown styled like a listbox trigger.
func FormCombobox(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 = selected.Label
if p.MaxDisplayLength > 0 {
r := []rune(display)
if len(r) > p.MaxDisplayLength {
display = strings.TrimRight(string(r[:p.MaxDisplayLength]), " ") + "…"
}
}
placeholderCls = ""
}
chevronCls := "text-neutral-400"
if p.OnDark {
chevronCls = "text-text-on-dark-muted"
}
chevron := "chevron-down"
if p.Open {
chevron = "chevron-up"
}
triggerMods := []vdom.Mod{
vdom.Attr("type", "button"),
vdom.Attr("class", formTriggerCls(p.Small, p.OnDark)),
vdom.Span(vdom.Attr("class", cx("min-w-0 truncate", placeholderCls)), vdom.Text(display)),
IconInline(chevron, 16, chevronCls),
}
if p.Disabled {
triggerMods = append(triggerMods, vdom.Attr("disabled", "disabled"))
}
if p.OnToggle != nil {
triggerMods = append(triggerMods, vdom.On(vdom.EVENT_CLICK, p.OnToggle))
}
rootMods := []vdom.Mod{
vdom.Attr("class", formComboboxRootCls(p.Class, p.FieldWidth)),
vdom.Button(triggerMods...),
}
if p.Open {
dropdownCls := formDropdown
if p.OnDark {
dropdownCls = formDropdownDark
}
ddMods := []vdom.Mod{vdom.Attr("class", cx("absolute left-0 top-full mt-1 z-50 w-full", dropdownCls))}
if p.Searchable {
searchWrapCls := formDropdownSearchWrap
searchInputCls := formDropdownSearchInput
if p.OnDark {
searchWrapCls = formDropdownSearchWrapDark
searchInputCls = formDropdownSearchInputDark
}
ddMods = append(ddMods, vdom.Div(vdom.Attr("class", searchWrapCls),
vdom.Input(vdom.Attr("type", "text"),
vdom.Attr("class", searchInputCls),
vdom.Attr("placeholder", pick(p.SearchPlaceholder, "Search...")),
),
))
}
optionCls := formDropdownOption
if p.OnDark {
optionCls = formDropdownOptionDark
}
if p.Small {
optionCls = cx(optionCls, "py-1.5 px-2")
}
if len(p.Options) == 0 {
noResultsCls := formDropdownNoResults
if p.OnDark {
noResultsCls = formDropdownNoResultsDark
}
ddMods = append(ddMods, vdom.Div(vdom.Attr("class", noResultsCls), vdom.Text("No options found")))
}
for _, opt := range p.Options {
ov := opt.Value
optMods := []vdom.Mod{vdom.Attr("type", "button"), vdom.Attr("class", optionCls)}
if opt.Disabled {
optMods = append(optMods, vdom.Attr("disabled", "disabled"))
} else if p.OnChange != nil {
oc := p.OnChange
optMods = append(optMods, vdom.On(vdom.EVENT_CLICK, func() { oc(ov) }))
}
optMods = append(optMods, vdom.Text(opt.Label))
ddMods = append(ddMods, vdom.Button(optMods...))
}
rootMods = append(rootMods, vdom.Div(ddMods...))
}
return vdom.Div(rootMods...)
}
// FormSearchableSelect is a FormCombobox with the search box shown.
func FormSearchableSelect(p FormComboboxProps) *vdom.VNode {
p.Searchable = true
return FormCombobox(p)
}
// FormMultiSelectProps configures FormMultiSelect.
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
// Open/OnToggle replace the internal open signal (see NOTE above).
Open bool
OnToggle func()
}
// FormMultiSelect is a multi-select dropdown with removable tags and an optional
// select-all row. Selection is functional through Value + OnChange.
func FormMultiSelect(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)
}
}
// Trigger content: placeholder, tag list, or "N items selected".
var triggerContent *vdom.VNode
switch {
case len(selected) == 0:
triggerContent = vdom.Span(vdom.Attr("class", "text-neutral-500"), vdom.Text(pick(p.Placeholder, "Select options")))
case len(selected) > maxTags:
n := len(selected)
suffix := "s"
if n == 1 {
suffix = ""
}
triggerContent = vdom.Span(vdom.Text(strconv.Itoa(n) + " item" + suffix + " selected"))
default:
tagSize := "py-0.5 px-2 text-sm"
if p.Small {
tagSize = "py-0.5 px-1.5 text-xs"
}
tagCls := cx("inline-flex items-center gap-0.5 bg-neutral-200 rounded-default whitespace-nowrap leading-none", tagSize)
tagsMods := []vdom.Mod{vdom.Attr("class", "flex flex-nowrap gap-1 overflow-hidden items-center")}
for _, opt := range selected {
ov := opt.Value
// NOTE: the remove button nests inside the trigger button (as in the TSX);
// the neutral Event has no stopPropagation, so a remove click also toggles
// the dropdown. Harmless given selection is idempotent through OnChange.
rmMods := []vdom.Mod{
vdom.Attr("type", "button"),
vdom.Attr("class", "bg-transparent border-none p-0 cursor-pointer flex items-center hover:text-neutral-900"),
IconInline("xmark", 10, ""),
}
if p.OnChange != nil {
oc := p.OnChange
cur := p.Value
rmMods = append(rmMods, vdom.On(vdom.EVENT_CLICK, func() { oc(formRemoveStr(cur, ov)) }))
}
tag := vdom.Span(vdom.Attr("class", tagCls),
vdom.Text(opt.Label),
vdom.Button(rmMods...),
)
tagsMods = append(tagsMods, tag)
}
triggerContent = vdom.Div(tagsMods...)
}
pad := "p-2"
if p.Small {
pad = "p-1"
}
triggerCls := cx(formTriggerBase, "overflow-hidden", formControlH(p.Small), pad)
chevron := "chevron-down"
if p.Open {
chevron = "chevron-up"
}
triggerMods := []vdom.Mod{
vdom.Attr("type", "button"),
vdom.Attr("class", triggerCls),
vdom.Div(vdom.Attr("class", "flex-1 overflow-hidden flex items-center min-w-0"), triggerContent),
IconInline(chevron, 16, "text-neutral-400"),
}
if p.Disabled {
triggerMods = append(triggerMods, vdom.Attr("disabled", "disabled"))
}
if p.OnToggle != nil {
triggerMods = append(triggerMods, vdom.On(vdom.EVENT_CLICK, p.OnToggle))
}
rootMods := []vdom.Mod{
vdom.Attr("class", formComboboxRootCls(p.Class, p.FieldWidth)),
vdom.Button(triggerMods...),
}
if p.Open {
ddMods := []vdom.Mod{vdom.Attr("class", cx("absolute left-0 top-full mt-1 z-50 w-full", formDropdown))}
if p.Searchable {
ddMods = append(ddMods, vdom.Div(vdom.Attr("class", formDropdownSearchWrap),
vdom.Input(vdom.Attr("type", "text"),
vdom.Attr("class", formDropdownSearchInput),
vdom.Attr("placeholder", pick(p.SearchPlaceholder, "Search...")),
),
))
}
if p.ShowSelectAll && len(p.Options) > 0 {
all := formAllSelected(p.Options, p.Value)
label := "Select All"
if all {
label = "Deselect All"
}
saMods := []vdom.Mod{vdom.Attr("type", "button"), vdom.Attr("class", formSelectAllBtn), vdom.Text(label)}
if p.OnChange != nil {
oc := p.OnChange
opts := p.Options
cur := p.Value
allNow := all
saMods = append(saMods, vdom.On(vdom.EVENT_CLICK, func() {
if allNow {
oc(formDeselectAll(opts, cur))
} else {
oc(formSelectAllValues(opts, cur))
}
}))
}
ddMods = append(ddMods, vdom.Div(vdom.Attr("class", formSelectAllWrap), vdom.Button(saMods...)))
}
if len(p.Options) == 0 {
ddMods = append(ddMods, vdom.Div(vdom.Attr("class", formDropdownNoResults), vdom.Text("No options found")))
}
for _, opt := range p.Options {
ov := opt.Value
cbMods := []vdom.Mod{vdom.Attr("type", "checkbox"), vdom.Attr("style", "pointer-events:none")}
if formContainsStr(p.Value, opt.Value) {
cbMods = append(cbMods, vdom.Attr("checked", "checked"))
}
optMods := []vdom.Mod{
vdom.Attr("type", "button"),
vdom.Attr("class", formDropdownOption),
vdom.Input(cbMods...),
vdom.Text(opt.Label),
}
if opt.Disabled {
optMods = append(optMods, vdom.Attr("disabled", "disabled"))
} else if p.OnChange != nil {
oc := p.OnChange
cur := p.Value
optMods = append(optMods, vdom.On(vdom.EVENT_CLICK, func() { oc(formToggleStr(cur, ov)) }))
}
ddMods = append(ddMods, vdom.Button(optMods...))
}
rootMods = append(rootMods, vdom.Div(ddMods...))
}
return vdom.Div(rootMods...)
}
// -- []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.)