395 lines
11 KiB
Go
395 lines
11 KiB
Go
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:]
|
|
}
|