1576 lines
49 KiB
Go
1576 lines
49 KiB
Go
package webui
|
|
|
|
import (
|
|
"strconv"
|
|
"strings"
|
|
|
|
"kjol/wasmruntime"
|
|
"kjol/wasmruntime/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-surface block w-full border rounded-default shadow-xs text-sm focus:outline-2 focus:outline-offset-1 disabled:bg-surface-raised 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 dark:text-red-400 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-surface border border-line-strong rounded-default shadow-xs text-sm w-full text-left flex items-center justify-between gap-2 cursor-pointer disabled:bg-surface-raised 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-surface border border-line-strong 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-surface border-b border-line p-2"
|
|
const formDropdownSearchWrapDark = "sticky top-0 bg-dark-raised border-b border-border-on-dark p-2"
|
|
const formDropdownSearchInput = "w-full bg-surface border border-line-strong 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-surface-raised disabled:text-ink-faint 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-ink-muted text-center"
|
|
const formDropdownNoResultsDark = "p-2 text-sm text-text-on-dark-muted text-center"
|
|
const formSelectAllWrap = "border-b border-line"
|
|
const formSelectAllBtn = "w-full text-left p-2 text-sm cursor-pointer text-ink-soft font-medium bg-transparent border-none hover:bg-surface-raised"
|
|
|
|
// -- 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-line-strong 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-surface-raised border shadow-xs border-r-0 flex items-center shrink-0 rounded-l-default"
|
|
borderNormal := "border-line-strong"
|
|
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-ink-soft"
|
|
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-line-strong focus:outline-sky-500 cursor-pointer",
|
|
"file:ml-1 file:mr-2 file:bg-surface-raised file:border file:border-line-strong file:rounded-default file:shadow-xs file:text-sm file:cursor-pointer file:hover:bg-surface-strong",
|
|
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-line-strong rounded-default py-3 px-4", class)),
|
|
vdom.Legend(vdom.Attr("class", "px-2 text-sm font-medium text-ink-soft"), 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
|
|
|
|
// multi drives the parts of a row that differ between picking ONE thing and picking
|
|
// SEVERAL: a multi-select's rows carry a checkbox, because the row's job there is to
|
|
// show a state you toggle, not an item you pick.
|
|
multi bool
|
|
|
|
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, multi bool) *dropdown {
|
|
d := &dropdown{
|
|
multi: multi,
|
|
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)
|
|
}
|
|
},
|
|
})
|
|
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-surface-raised")
|
|
}
|
|
|
|
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))
|
|
}
|
|
|
|
if d.multi {
|
|
mods = append(mods, optionCheckbox(selected))
|
|
}
|
|
return vdom.Button(append(mods, vdom.Text(opt.Label))...)
|
|
}
|
|
|
|
// optionCheckbox is the tick on a multi-select row.
|
|
//
|
|
// It is inert: pointer-events are off and it has no change handler, so the click always
|
|
// lands on the row BUTTON that owns it. A checkbox that could be clicked independently
|
|
// of its row would let the two disagree about what is selected.
|
|
//
|
|
// `checked` is a PROP, not an attribute. The attribute is only the DOM's initial value;
|
|
// once the browser owns the property, writing the attribute again does nothing — so an
|
|
// attribute-driven tick would be right on the first paint and then never change again.
|
|
func optionCheckbox(selected bool) vdom.Mod {
|
|
return vdom.Input(
|
|
vdom.Attr("type", "checkbox"),
|
|
vdom.Attr("tabindex", "-1"),
|
|
vdom.Attr("aria-hidden", "true"),
|
|
vdom.Attr("class", "pointer-events-none shrink-0"),
|
|
vdom.BoolProp("checked", selected),
|
|
)
|
|
}
|
|
|
|
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, false)} // picks one — no checkboxes
|
|
}
|
|
|
|
// 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-ink-muted"
|
|
if p.OnDark {
|
|
placeholderCls = "text-text-on-dark-muted"
|
|
}
|
|
if selected != nil {
|
|
display = truncateRunes(selected.Label, p.MaxDisplayLength)
|
|
placeholderCls = ""
|
|
}
|
|
|
|
chevronCls := "text-ink-faint"
|
|
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
|
|
|
|
// tagsRef / countRef are the two faces of the trigger: the row of pills, and the
|
|
// "N items selected" summary that replaces them when they do not fit. Both are
|
|
// always rendered; which one is visible is decided by measurement, not by a signal.
|
|
tagsRef *vdom.Ref
|
|
countRef *vdom.Ref
|
|
}
|
|
|
|
// NewMultiSelect creates a multi-select dropdown.
|
|
func NewMultiSelect(o DropdownOptions) *MultiSelect {
|
|
return &MultiSelect{
|
|
dropdown: newDropdown(o, true), // picks several — every row gets a checkbox
|
|
tagsRef: vdom.NewRef(),
|
|
countRef: vdom.NewRef(),
|
|
}
|
|
}
|
|
|
|
// 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
|
|
|
|
// CollapsedLabel names the selection once the pills no longer fit. It defaults to
|
|
// "N items selected", which is right for a list of things and wrong for anything
|
|
// that is not — a column picker reading "5 items selected" tells you the count and
|
|
// hides what the count is OF.
|
|
CollapsedLabel func(n int) 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)
|
|
}
|
|
}
|
|
|
|
pad := "p-2"
|
|
if p.Small {
|
|
pad = "p-1"
|
|
}
|
|
chevron := "chevron-down"
|
|
if m.IsOpen() {
|
|
chevron = "chevron-up"
|
|
}
|
|
|
|
face := append([]vdom.Mod{vdom.Attr("class", "flex-1 overflow-hidden flex items-center min-w-0")},
|
|
m.triggerFace(p, selected, maxTags)...)
|
|
|
|
trigger := m.f.Trigger(FloatingTriggerProps{
|
|
Class: cx(formTriggerBase, "overflow-hidden", formControlH(p.Small), pad),
|
|
AriaHasPopup: "listbox",
|
|
},
|
|
vdom.Div(face...),
|
|
IconInline(chevron, 16, "text-ink-faint"),
|
|
)
|
|
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 {
|
|
rows = append(rows, m.selectAllButton(filtered, p.Value, p.OnChange))
|
|
}
|
|
|
|
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...),
|
|
)
|
|
}
|
|
|
|
// selectAllButton is the row at the top of the panel.
|
|
//
|
|
// It 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, and one they would not discover until they saved.
|
|
func (m *MultiSelect) selectAllButton(filtered []FormSelectOption, value []string, onChange func([]string)) *vdom.VNode {
|
|
all := formAllSelected(filtered, value)
|
|
label := "Select All"
|
|
if all {
|
|
label = "Deselect All"
|
|
}
|
|
mods := []vdom.Mod{vdom.Attr("type", "button"), vdom.Attr("class", formSelectAllBtn), vdom.Text(label)}
|
|
if onChange != nil {
|
|
mods = append(mods, vdom.On(vdom.EVENT_CLICK, func() {
|
|
if all {
|
|
next := value
|
|
for _, o := range filtered {
|
|
next = formRemoveStr(next, o.Value)
|
|
}
|
|
onChange(next)
|
|
return
|
|
}
|
|
onChange(formSelectAllValues(filtered, value))
|
|
}))
|
|
}
|
|
return vdom.Button(mods...)
|
|
}
|
|
|
|
// triggerFace is what the closed field shows: a placeholder, a row of removable pills,
|
|
// or "N items selected" when the pills will not fit.
|
|
//
|
|
// It renders the pills AND the summary every time, and lets a measurement decide which
|
|
// one is displayed — because "will not fit" is a question only the browser can answer,
|
|
// and answering it by writing a signal would re-render the tree that the answer depends
|
|
// on. See collapseFace.
|
|
func (m *MultiSelect) triggerFace(p FormMultiSelectProps, selected []FormSelectOption, maxTags int) []vdom.Mod {
|
|
if len(selected) == 0 {
|
|
return []vdom.Mod{vdom.Span(
|
|
vdom.Attr("class", "text-ink-muted truncate"),
|
|
vdom.Text(pick(p.Placeholder, "Select options")),
|
|
)}
|
|
}
|
|
|
|
// Too many to be worth showing as pills at all. This needs no measurement, so it is
|
|
// decided here in the markup and holds on the SERVER too — SSR gets the summary
|
|
// rather than a row of clipped pills it would then have to swap out.
|
|
tooMany := len(selected) > maxTags
|
|
|
|
pillCls := formMultiSelectTag
|
|
if p.Small {
|
|
pillCls = cx(pillCls, "px-1.5 text-xs")
|
|
} else {
|
|
pillCls = cx(pillCls, "px-2 text-sm")
|
|
}
|
|
|
|
// flex-nowrap + overflow-hidden is what makes the overflow MEASURABLE: the pills lay
|
|
// out on one line and are clipped, so scrollWidth exceeds clientWidth by exactly the
|
|
// amount that does not fit. With flex-wrap they would simply wrap and never overflow
|
|
// horizontally, and nothing would ever collapse.
|
|
tags := []vdom.Mod{
|
|
vdom.WithRef(m.tagsRef),
|
|
vdom.Attr("class", faceCls("flex flex-nowrap items-center gap-1 min-w-0 overflow-hidden", tooMany)),
|
|
}
|
|
for _, opt := range selected {
|
|
ov, label := opt.Value, opt.Label
|
|
remove := []vdom.Mod{
|
|
vdom.Attr("type", "button"),
|
|
vdom.Attr("class", "bg-transparent border-none p-0 cursor-pointer flex items-center hover:text-ink"),
|
|
vdom.Attr("aria-label", "Remove "+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 pill sits INSIDE the trigger, so without this the click would bubble
|
|
// up and open the panel as it removed the pill.
|
|
e.StopPropagation()
|
|
oc(formRemoveStr(cur, ov))
|
|
}))
|
|
}
|
|
tags = append(tags, vdom.Span(vdom.Attr("class", pillCls),
|
|
vdom.Text(label),
|
|
vdom.Button(remove...),
|
|
))
|
|
}
|
|
|
|
label := itemsSelected
|
|
if p.CollapsedLabel != nil {
|
|
label = p.CollapsedLabel
|
|
}
|
|
count := vdom.Span(
|
|
vdom.WithRef(m.countRef),
|
|
vdom.Attr("class", faceCls("truncate", !tooMany)),
|
|
vdom.Text(label(len(selected))),
|
|
)
|
|
|
|
m.collapseFace(tooMany)
|
|
return []vdom.Mod{vdom.Div(tags...), count}
|
|
}
|
|
|
|
// collapseFace picks which face is displayed, after the DOM exists.
|
|
//
|
|
// The choice is written with SetStyle, not a signal: a signal write re-renders the
|
|
// whole tree, and this decision is DERIVED from that tree's layout — the render would
|
|
// feed the measurement that triggers the render. Doing it imperatively also means the
|
|
// pills and the summary can both be in the DOM at once without ever both being seen.
|
|
//
|
|
// AfterRender runs after the commit but before the browser paints, so every write here
|
|
// — including the deliberate flash of the pills back into view to measure them — lands
|
|
// in the same frame. Nothing is visible mid-decision.
|
|
func (m *MultiSelect) collapseFace(tooMany bool) {
|
|
wasmruntime.AfterRender(func() {
|
|
collapse := tooMany
|
|
if !collapse {
|
|
// Measure from a known state. A previous render may have left the pills
|
|
// display:none, and a hidden element has no layout — it would report no
|
|
// overflow and we would wrongly un-collapse.
|
|
wasmruntime.SetStyle(m.tagsRef, "display", "flex")
|
|
wasmruntime.SetStyle(m.countRef, "display", "none")
|
|
if !wasmruntime.OverflowsX(m.tagsRef) {
|
|
return
|
|
}
|
|
}
|
|
wasmruntime.SetStyle(m.tagsRef, "display", "none")
|
|
wasmruntime.SetStyle(m.countRef, "display", "block")
|
|
})
|
|
}
|
|
|
|
// faceCls hides one of the two faces by class, which is what the SERVER renders (it
|
|
// cannot measure, so it ships the unmeasured choice). On the client the inline styles
|
|
// written by collapseFace take over, and they win over a class.
|
|
func faceCls(cls string, hidden bool) string {
|
|
if hidden {
|
|
return cx(cls, "hidden")
|
|
}
|
|
return cls
|
|
}
|
|
|
|
// itemsSelected is the collapsed summary: "1 item selected", "4 items selected".
|
|
func itemsSelected(n int) string {
|
|
if n == 1 {
|
|
return "1 item selected"
|
|
}
|
|
return strconv.Itoa(n) + " items selected"
|
|
}
|
|
|
|
const formMultiSelectTag = "inline-flex items-center gap-0.5 rounded-default bg-surface-strong py-0.5 text-ink-soft whitespace-nowrap leading-none"
|
|
|
|
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"},
|
|
}
|
|
|
|
// -- the rest of the TSX kit ---------------------------------------------------
|
|
//
|
|
// These four were once listed here as unportable — "no neutral-runtime equivalent".
|
|
// That was true before the host API existed. It is not true now, and they all live in
|
|
// forms_async.go and signaturepad.go:
|
|
//
|
|
// - SignaturePad — draws into an SVG rather than a canvas, so the thing you
|
|
// sign IS the value the caller stores.
|
|
// - AsyncCombobox — debounced, and it discards out-of-order responses.
|
|
// - MultiSelectTrigger — MultiSelect behind a trigger of your own.
|
|
// - MaskTaxID / MaskRate — the TSX's oninput handlers, as pure functions of the
|
|
// string. Testable, reusable, and they run on the server.
|