1263 lines
39 KiB
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.)
|