package webui

import (
	"strconv"
	"strings"

	"kjol/vdom"
	"kjol/wasmruntime"
)

// Port of web/kit/Forms.tsx. Reactive accessors collapse to plain values, and
// value/onChange model as a plain `Value string` field + `OnChange func(string)`
// callback. The advanced combobox/multi-select controls in the TSX rely on
// solid-js/web Portals + floating-ui measurement + refs + document listeners,
// none of which exist in the neutral runtime; those are approximated here (see
// the NOTE comments) with static, CSS-positioned structure and signal-free
// open/toggle wiring. Genuinely browser-only controls are omitted (see bottom).
//
// Naming: the TSX exports are all `Form…`-prefixed, so none collide with the
// vdom tag builders Form/Input/Label/Table; they are kept as-is. The exported
// data table US_STATES becomes USStates (Go casing). Unexported helpers/consts
// are `form`-prefixed per the package convention.

// -- shared Tailwind class strings (verbatim from Forms.tsx) -------------------

const formInputBase = "bg-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.
