package webui import ( "strings" "kjol/wasmruntime" "kjol/wasmruntime/vdom" ) // The three form controls the first pass left out, plus the two input masks. All of // them were listed as "no neutral-runtime equivalent" — which was true before the host // API existed, and is not true now. // ---- AsyncCombobox: options fetched as you type -------------------------- // AsyncCombobox is a combobox whose options come from somewhere else: a search endpoint, // a database, a third-party API. You type, it asks, the answers appear. // // The asking is DEBOUNCED and the answers are ORDERED. Both matter and both are easy to // get wrong: without debouncing, a six-letter query is six requests; without ordering, a // slow response to "ab" can land after a fast one to "abcdef" and overwrite it, leaving // the user staring at results for a query they finished typing a second ago. Every // response carries the query it was for, and one that no longer matches the box is // dropped. // // Create it once, alongside your signals — never inside a render. type AsyncCombobox struct { f *Floating query *vdom.Signal[string] results *vdom.Signal[[]FormSelectOption] loading *vdom.Signal[bool] active *vdom.Signal[int] inputRef *vdom.Ref timer int minChars int debounce int search func(query string, done func([]FormSelectOption)) } // AsyncComboboxOptions configures NewAsyncCombobox. type AsyncComboboxOptions struct { // Search is asked for options. Call done with the results — from a goroutine, from a // fetch callback, whenever. Call it exactly once; calling it late is fine, since a // stale answer is discarded rather than shown. Search func(query string, done func([]FormSelectOption)) MinChars int // don't search below this many characters (default 2) DebounceMs int // wait this long after the last keystroke (default 200) Placement string OnOpenChange func(bool) } // NewAsyncCombobox creates an async combobox. func NewAsyncCombobox(o AsyncComboboxOptions) *AsyncCombobox { c := &AsyncCombobox{ query: vdom.NewSignal(""), results: vdom.NewSignal([]FormSelectOption{}), loading: vdom.NewSignal(false), active: vdom.NewSignal(-1), inputRef: vdom.NewRef(), minChars: o.MinChars, debounce: o.DebounceMs, search: o.Search, } if c.minChars == 0 { c.minChars = 2 } if c.debounce == 0 { c.debounce = 200 } c.f = NewFloating(FloatingOptions{ Placement: pick(o.Placement, PlacementBottomStart), Offset: 4, ConstrainToViewport: true, Standalone: true, OnOpenChange: func(open bool) { if !open { c.active.Set(-1) } if o.OnOpenChange != nil { o.OnOpenChange(open) } }, }) return c } // IsOpen / Close drive the panel. func (c *AsyncCombobox) IsOpen() bool { return c.f.IsOpen() } func (c *AsyncCombobox) Close() { c.f.Hide() } // Dispose cancels a pending search and removes the panel's listeners. func (c *AsyncCombobox) Dispose() { wasmruntime.ClearTimeout(c.timer) c.f.Dispose() } // FormAsyncComboboxProps configures a render. type FormAsyncComboboxProps struct { Placeholder string // OnSelect fires when an option is chosen. The box shows the option's label. OnSelect func(opt FormSelectOption) Disabled bool Small bool Class string FieldWidth string // EmptyMessage is shown when a search returns nothing (default "No matches"). EmptyMessage string } // Render draws the field and its panel. func (c *AsyncCombobox) Render(p FormAsyncComboboxProps) *vdom.VNode { input := vdom.Input( vdom.WithRef(c.inputRef), vdom.Attr("type", "text"), vdom.Attr("class", cx(formInputBase, formControlH(p.Small), "border-none bg-transparent p-0 shadow-none focus-visible:outline-none")), vdom.Attr("placeholder", pick(p.Placeholder, "Search…")), vdom.Attr("autocomplete", "off"), vdom.Attr("spellcheck", "false"), vdom.Prop("value", c.query.Get()), vdom.OnEvent(vdom.EVENT_INPUT, func(e vdom.Event) { c.onInput(e.Value()) }), vdom.OnEvent(vdom.EVENT_KEYDOWN, func(e vdom.Event) { c.onKey(e, p) }), ) if p.Disabled { input.Attrs["disabled"] = "disabled" } pad := "p-2" if p.Small { pad = "p-1" } trigger := c.f.Trigger(FloatingTriggerProps{ Tag: "div", Class: cx(formTriggerBase, formControlH(p.Small), pad), AriaHasPopup: "listbox", // The field is not a switch — see FloatingTriggerProps.NoToggle. NoToggle: true, }, vdom.Div(vdom.Attr("class", "min-w-0 flex-1"), input), c.statusIcon(), ) return vdom.Div( vdom.Attr("class", formComboboxRootCls(p.Class, p.FieldWidth)), trigger, c.panel(p), ) } func (c *AsyncCombobox) statusIcon() *vdom.VNode { if c.loading.Get() { return vdom.Span(vdom.Attr("class", "shrink-0 animate-spin text-ink-faint"), IconInline("circle-notch", 14, "")) } return IconInline("magnifying-glass", 14, "shrink-0 text-ink-faint") } func (c *AsyncCombobox) panel(p FormAsyncComboboxProps) *vdom.VNode { rows := []*vdom.VNode{} switch { case c.loading.Get(): rows = append(rows, vdom.Div(vdom.Attr("class", formDropdownNoResults), vdom.Text("Searching…"))) case len(c.results.Get()) == 0: rows = append(rows, vdom.Div(vdom.Attr("class", formDropdownNoResults), vdom.Text(pick(p.EmptyMessage, "No matches")))) default: for i, opt := range c.results.Get() { i, opt := i, opt cls := formDropdownOption if i == c.active.Get() { cls = cx(cls, "bg-surface-raised") } rows = append(rows, vdom.Button( vdom.Attr("type", "button"), vdom.Attr("class", cls), vdom.Attr("role", "option"), vdom.On(vdom.EVENT_CLICK, func() { c.choose(opt, p) }), vdom.Text(opt.Label), )) } } return c.f.Panel(FloatingPanelProps{Class: formDropdown, Role: "listbox"}, rows...) } func (c *AsyncCombobox) choose(opt FormSelectOption, p FormAsyncComboboxProps) { c.query.Set(opt.Label) c.f.Hide() if p.OnSelect != nil { p.OnSelect(opt) } } // onInput debounces. A search per keystroke is a search per keystroke — and if the // search costs a network round-trip, typing "engineering" is twelve of them. func (c *AsyncCombobox) onInput(q string) { c.query.Set(q) c.active.Set(-1) wasmruntime.ClearTimeout(c.timer) if len([]rune(strings.TrimSpace(q))) < c.minChars { c.loading.Set(false) c.results.Set([]FormSelectOption{}) c.f.Hide() return } c.loading.Set(true) c.f.Show() c.timer = wasmruntime.SetTimeout(c.debounce, func() { c.run(q) }) } // run performs the search, and DISCARDS an answer that arrived for a query the user has // since typed past. Out-of-order responses are the classic bug in this control. func (c *AsyncCombobox) run(q string) { if c.search == nil { c.loading.Set(false) return } c.search(q, func(opts []FormSelectOption) { if c.query.Get() != q { return // the box has moved on; this answer is for a question nobody is asking } c.loading.Set(false) c.results.Set(opts) if len(opts) > 0 { c.f.Show() } }) } func (c *AsyncCombobox) onKey(e vdom.Event, p FormAsyncComboboxProps) { res := c.results.Get() switch e.Key() { case vdom.KEY_ARROW_DOWN: e.PreventDefault() if len(res) > 0 { c.active.Set((c.active.Get() + 1) % len(res)) } case vdom.KEY_ARROW_UP: e.PreventDefault() if len(res) > 0 { next := c.active.Get() - 1 if next < 0 { next = len(res) - 1 } c.active.Set(next) } case vdom.KEY_ENTER: if i := c.active.Get(); i >= 0 && i < len(res) { e.PreventDefault() c.choose(res[i], p) } } } // ---- MultiSelectTrigger: a multi-select behind your own trigger ---------- // MultiSelectTrigger is MultiSelect with the field replaced by whatever you like — a // button, an icon, a table header. The selection model is identical; only the thing you // click on differs. The AutoTable's column picker is one of these in spirit. type MultiSelectTrigger struct{ *MultiSelect } // NewMultiSelectTrigger creates one. func NewMultiSelectTrigger(o DropdownOptions) *MultiSelectTrigger { return &MultiSelectTrigger{MultiSelect: NewMultiSelect(o)} } // FormMultiSelectTriggerProps configures a render. type FormMultiSelectTriggerProps struct { // Trigger is your element. It is wrapped in the floating trigger, so it needs no // open/close wiring of its own. Trigger *vdom.VNode Options []FormSelectOption Value []string OnChange func([]string) Searchable bool SearchPlaceholder string ShowSelectAll bool Small bool Class string PanelClass string } // Render draws the caller's trigger and the panel. func (m *MultiSelectTrigger) Render(p FormMultiSelectTriggerProps) *vdom.VNode { trigger := m.f.Trigger(FloatingTriggerProps{ Tag: "div", Class: cx("inline-flex cursor-pointer items-center", p.Class), AriaHasPopup: "listbox", }, p.Trigger) 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))) } 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)) } })) } return vdom.Div(vdom.Attr("class", "relative inline-flex"), trigger, m.f.Panel(FloatingPanelProps{Class: cx(formDropdown, pick(p.PanelClass, "min-w-48")), Role: "listbox"}, rows...), ) } // ---- input masks -------------------------------------------------------- // MaskTaxID formats a US tax ID as the user types: 12-3456789. // // The TSX equivalents (handleTaxIdInput / handleRateInput) were oninput handlers that // reached into the event and rewrote the element's value in place. These are pure // functions of the string instead — they can be tested, reused on the server, and // composed — and the caller wires one into an input the ordinary way: // // ui.FormInput(ui.FormInputProps{ // Value: taxID.Get(), // OnInput: func(v string) { taxID.Set(ui.MaskTaxID(v)) }, // }) func MaskTaxID(s string) string { digits := keepDigits(s) if len(digits) > 9 { digits = digits[:9] } if len(digits) <= 2 { return digits } return digits[:2] + "-" + digits[2:] } // MaskRate formats a rate as it is typed: digits, one decimal point, at most three // decimal places, no leading zeros. func MaskRate(s string) string { var b strings.Builder seenDot := false for i := 0; i < len(s); i++ { c := s[i] switch { case c >= '0' && c <= '9': b.WriteByte(c) case c == '.' && !seenDot: seenDot = true b.WriteByte(c) } } out := b.String() if i := strings.IndexByte(out, '.'); i >= 0 { whole, frac := out[:i], out[i+1:] if len(frac) > 3 { frac = frac[:3] } out = trimLeadingZeros(whole) + "." + frac } else { out = trimLeadingZeros(out) } return out } func keepDigits(s string) string { var b strings.Builder for i := 0; i < len(s); i++ { if s[i] >= '0' && s[i] <= '9' { b.WriteByte(s[i]) } } return b.String() } // trimLeadingZeros drops leading zeros but keeps a lone "0" — "007" is 7, and "0" is 0, // but "" is not a number the user meant to type. func trimLeadingZeros(s string) string { i := 0 for i < len(s)-1 && s[i] == '0' { i++ } return s[i:] }