Files
kjol/go/webui/forms_async.go

395 lines
11 KiB
Go

package webui
import (
"strings"
"kjol/vdom"
"kjol/wasmruntime"
)
// 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:]
}