Add landing page for kjol, documentation
This commit is contained in:
394
go/webui/forms_async.go
Normal file
394
go/webui/forms_async.go
Normal file
@@ -0,0 +1,394 @@
|
||||
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:]
|
||||
}
|
||||
Reference in New Issue
Block a user