fix multiselect combobox

This commit is contained in:
2026-07-13 15:21:42 -04:00
parent d52151cc1a
commit 5230bd6702
11 changed files with 664 additions and 58 deletions

View File

@@ -1,6 +1,8 @@
package app package app
import ( import (
"strings"
. "kjol/vdom" . "kjol/vdom"
ui "kjol/webui" ui "kjol/webui"
) )
@@ -31,6 +33,20 @@ func ptRow(name, plan string, status *VNode) *VNode {
) )
} }
// languageOptions is deliberately longer than the pill limit, so the multi-select
// demonstrates both ways it collapses: past 3 selections it says "N items selected"
// outright, and below that it still collapses if the pills are too wide for the field.
func languageOptions() []ui.FormSelectOption {
return []ui.FormSelectOption{
{Value: "go", Label: "Go"},
{Value: "rust", Label: "Rust"},
{Value: "ts", Label: "TypeScript"},
{Value: "python", Label: "Python"},
{Value: "kotlin", Label: "Kotlin"},
{Value: "swift", Label: "Swift"},
}
}
//gowasm:page /kit layout=app //gowasm:page /kit layout=app
func KitPage(d Deps) func() *VNode { func KitPage(d Deps) func() *VNode {
// Interactive demos own their state via signals (a write re-renders). // Interactive demos own their state via signals (a write re-renders).
@@ -41,6 +57,7 @@ func KitPage(d Deps) func() *VNode {
name := NewSignal("") name := NewSignal("")
email := NewSignal("") email := NewSignal("")
plan := NewSignal("pro") plan := NewSignal("pro")
langs := NewSignal([]string{"go"})
// Floating components are CONTROLLERS: they own refs, timers and open state, so // Floating components are CONTROLLERS: they own refs, timers and open state, so
// they are built once here — never inside the render closure below, which would // they are built once here — never inside the render closure below, which would
@@ -48,6 +65,7 @@ func KitPage(d Deps) func() *VNode {
modal := ui.NewModal(ui.ModalOptions{Size: ui.ModalMedium}) modal := ui.NewModal(ui.ModalOptions{Size: ui.ModalMedium})
menu := ui.NewMenu(ui.MenuOptions{Placement: ui.PlacementBottomStart}) menu := ui.NewMenu(ui.MenuOptions{Placement: ui.PlacementBottomStart})
tip := ui.NewHoverTooltip(ui.PlacementTop, "") tip := ui.NewHoverTooltip(ui.PlacementTop, "")
skills := ui.NewMultiSelect(ui.DropdownOptions{})
return func() *VNode { return func() *VNode {
return Div(Attr("class", "space-y-8"), return Div(Attr("class", "space-y-8"),
@@ -134,9 +152,23 @@ func KitPage(d Deps) func() *VNode {
ui.FormOption("free", "Free", false), ui.FormOption("free", "Free", false),
ui.FormOption("pro", "Pro", false), ui.FormOption("pro", "Pro", false),
ui.FormOption("enterprise", "Enterprise", false))), ui.FormOption("enterprise", "Enterprise", false))),
// A multi-select. Its rows carry checkboxes, and the field shows the
// selection as removable pills — until they stop fitting, at which point
// it collapses to "N items selected". Tick a few and watch it flip.
row("flex flex-col gap-1", ui.FormLabel(ui.FormLabelProps{}, Text("Languages")),
skills.Render(ui.FormMultiSelectProps{
Options: languageOptions(),
Value: langs.Get(),
Placeholder: "Pick a few",
Searchable: true,
ShowSelectAll: true,
OnChange: func(v []string) { langs.Set(v) },
})),
), ),
P(Attr("class", "text-xs text-neutral-500"), P(Attr("class", "text-xs text-neutral-500"),
Text("Live: name=\""+name.Get()+"\" email=\""+email.Get()+"\" plan=\""+plan.Get()+"\"")), Text("Live: name=\""+name.Get()+"\" email=\""+email.Get()+"\" plan=\""+plan.Get()+
"\" languages="+strings.Join(langs.Get(), ","))),
), ),
kitSection("Table", kitSection("Table",

File diff suppressed because one or more lines are too long

View File

@@ -7,6 +7,7 @@ package vdom
import ( import (
"html" "html"
"sort" "sort"
"strconv"
"strings" "strings"
) )
@@ -100,6 +101,41 @@ func (p propMod) apply(n *VNode) { n.Props[p.k] = p.v }
// Prop sets a live DOM property (e.g. an input's value). // Prop sets a live DOM property (e.g. an input's value).
func Prop(k, v string) Mod { return propMod{k, v} } func Prop(k, v string) Mod { return propMod{k, v} }
// BoolProp sets a boolean DOM property: checked, disabled, open, and the rest of
// boolProps below.
//
// Use this rather than Prop for those — a boolean written as a string is wrong in BOTH
// directions, and wrong in the same direction both times, which is what makes it such a
// good hiding place for a bug. In HTML a boolean attribute is presence-based, so
// `checked="false"` renders a TICKED box. In JS every non-empty string is truthy, so
// `el.checked = "false"` also ticks it. A checkbox bound with Prop is therefore ticked
// forever, and looks fine until you try to untick it.
func BoolProp(k string, on bool) Mod { return propMod{k, strconv.FormatBool(on)} }
// boolProps are the DOM properties whose type is boolean, so both the reconciler and
// SSR can special-case them (see BoolProp for why they must).
//
// This is a name table because the DOM is a name table: there is no way to ask, of a
// VNode alone, whether `checked` on this tag is a boolean. Every framework carries the
// same list.
var boolProps = map[string]bool{
"checked": true,
"disabled": true,
"readOnly": true,
"required": true,
"selected": true,
"multiple": true,
"hidden": true,
"open": true,
"autofocus": true,
"indeterminate": true,
"defaultChecked": true,
}
// IsBoolProp reports whether a prop name is a boolean DOM property. The reconciler
// needs it to write a real bool instead of a string.
func IsBoolProp(k string) bool { return boolProps[k] }
type htmlMod struct{ html string } type htmlMod struct{ html string }
func (h htmlMod) apply(n *VNode) { n.HTML = h.html } func (h htmlMod) apply(n *VNode) { n.HTML = h.html }
@@ -147,7 +183,7 @@ func writeNode(b *strings.Builder, n *VNode) {
b.WriteByte('<') b.WriteByte('<')
b.WriteString(n.Tag) b.WriteString(n.Tag)
writeAttrs(b, n.Attrs) writeAttrs(b, n.Attrs)
writeAttrs(b, n.Props) // props like input value show up as attributes in SSR writeProps(b, n.Props) // props like an input's value show up as attributes in SSR
b.WriteByte('>') b.WriteByte('>')
if voidTags[n.Tag] { if voidTags[n.Tag] {
return return
@@ -165,16 +201,44 @@ func writeNode(b *strings.Builder, n *VNode) {
} }
func writeAttrs(b *strings.Builder, m map[string]string) { func writeAttrs(b *strings.Builder, m map[string]string) {
for _, k := range sortedKeys(m) {
writeAttr(b, k, m[k])
}
}
// writeProps serializes live DOM properties as HTML attributes for SSR, so the
// server's markup shows what the client's props will hold.
//
// A boolean prop is emitted as a BARE attribute when true and omitted entirely when
// false — that is what the HTML boolean-attribute rule means. Writing checked="false"
// would render a ticked checkbox, which is the exact opposite of what was asked for.
func writeProps(b *strings.Builder, m map[string]string) {
for _, k := range sortedKeys(m) {
v := m[k]
if boolProps[k] {
if v == "true" {
b.WriteByte(' ')
b.WriteString(strings.ToLower(k))
}
continue
}
writeAttr(b, k, v)
}
}
func writeAttr(b *strings.Builder, k, v string) {
b.WriteByte(' ')
b.WriteString(k)
b.WriteString(`="`)
b.WriteString(html.EscapeString(v))
b.WriteByte('"')
}
func sortedKeys(m map[string]string) []string {
keys := make([]string, 0, len(m)) keys := make([]string, 0, len(m))
for k := range m { for k := range m {
keys = append(keys, k) keys = append(keys, k)
} }
sort.Strings(keys) sort.Strings(keys)
for _, k := range keys { return keys
b.WriteByte(' ')
b.WriteString(k)
b.WriteString(`="`)
b.WriteString(html.EscapeString(m[k]))
b.WriteByte('"')
}
} }

46
go/vdom/vnode_test.go Normal file
View File

@@ -0,0 +1,46 @@
package vdom
import (
"strings"
"testing"
)
// A boolean DOM property is a trap in HTML: the attribute's PRESENCE is what means
// true. checked="false" is a ticked checkbox — the value is not even read. So a false
// boolean prop must be omitted entirely, and a true one written bare.
func TestBoolPropSSR(t *testing.T) {
ticked := RenderHTML(Input(Attr("type", "checkbox"), BoolProp("checked", true)))
if !strings.Contains(ticked, " checked") {
t.Errorf("a checked box did not render the attribute: %s", ticked)
}
if strings.Contains(ticked, `checked="`) {
t.Errorf("a boolean attribute must be bare, not valued: %s", ticked)
}
unticked := RenderHTML(Input(Attr("type", "checkbox"), BoolProp("checked", false)))
if strings.Contains(unticked, "checked") {
t.Errorf(`an unchecked box must omit the attribute entirely — checked="false" renders as TICKED: %s`, unticked)
}
}
// Non-boolean props keep their values: an input's value is a string, and dropping it
// when empty would be just as wrong as writing checked="false".
func TestValuePropSSRKeepsItsValue(t *testing.T) {
got := RenderHTML(Input(Prop("value", "false")))
if !strings.Contains(got, `value="false"`) {
t.Errorf(`value="false" is a string, not a boolean, and must survive: %s`, got)
}
}
func TestIsBoolProp(t *testing.T) {
for _, k := range []string{"checked", "disabled", "selected", "open"} {
if !IsBoolProp(k) {
t.Errorf("%q should be known as a boolean property — the reconciler writes it as a string otherwise", k)
}
}
for _, k := range []string{"value", "className", "id"} {
if IsBoolProp(k) {
t.Errorf("%q is not a boolean property", k)
}
}
}

View File

@@ -30,6 +30,11 @@ func ScrollIntoView(*vdom.Ref, bool, string) {}
func ScrollLeft(*vdom.Ref) float64 { return 0 } func ScrollLeft(*vdom.Ref) float64 { return 0 }
func SetScrollLeft(*vdom.Ref, float64) {} func SetScrollLeft(*vdom.Ref, float64) {}
// OverflowsX is false on the server: with no layout, nothing can overflow. A
// component that collapses overflowing content therefore SSRs its uncollapsed form,
// and the client collapses it on the first commit — before paint, so it is not seen.
func OverflowsX(*vdom.Ref) bool { return false }
func Contains(*vdom.Ref, any) bool { return false } func Contains(*vdom.Ref, any) bool { return false }
func ClosestAttr(any, string, string) (string, bool) { return "", false } func ClosestAttr(any, string, string) (string, bool) { return "", false }
func QuerySelector(string) *vdom.Ref { return vdom.NewRef() } func QuerySelector(string) *vdom.Ref { return vdom.NewRef() }

View File

@@ -208,6 +208,30 @@ func SetScrollLeft(r *vdom.Ref, x float64) {
} }
} }
// OverflowsX reports whether an element's content is wider than the box it is
// clipped to — i.e. something is hidden.
//
// It compares scrollWidth (the full content) against clientWidth (the visible content
// box). Measure/getBoundingClientRect is the WRONG comparison here: it reports the
// element's own border box, which is by definition the size it was clipped to, so it
// can never reveal an overflow.
//
// A detached or display:none element has no layout and reports 0/0; that is not an
// overflow, so it answers false rather than a misleading true.
func OverflowsX(r *vdom.Ref) bool {
n, ok := node(r)
if !ok {
return false
}
client := n.Get("clientWidth").Float()
if client == 0 {
return false
}
// Sub-pixel layout means scrollWidth can exceed clientWidth by a hair on content
// that visually fits. Round up to whole pixels before believing it.
return n.Get("scrollWidth").Float() > client+1
}
// ---- hit testing (outside-click) ---- // ---- hit testing (outside-click) ----
// Contains reports whether target lies inside r's subtree. target is an // Contains reports whether target lies inside r's subtree. target is an

View File

@@ -0,0 +1,120 @@
//go:build js && wasm
package wasmruntime
import (
"syscall/js"
"testing"
"kjol/vdom"
)
// Run from kjol/go:
//
// GOOS=js GOARCH=wasm go test -exec="node testdata/domexec.js" ./wasmruntime
// Props travel through the vdom as strings, but `checked` is a BOOLEAN property, and in
// JavaScript every non-empty string is truthy — so `el.checked = "false"` ticks the box.
// A multi-select rendered that way shows every option as selected, forever.
//
// The reconciler must write a real boolean.
func TestBoolPropIsWrittenAsABoolean(t *testing.T) {
root := document.Call("createElement", "div")
box := vdom.NewRef()
unticked := vdom.Input(vdom.WithRef(box), vdom.BoolProp("checked", false))
patchChildren(root, nil, one(unticked))
el, _ := box.Node().(js.Value)
if got := el.Get("checked"); got.Type() != js.TypeBoolean {
t.Fatalf(`checked is a %v, not a boolean — the string "false" is truthy in JS`, got.Type())
}
if el.Get("checked").Bool() {
t.Error("BoolProp(false) produced a TICKED checkbox")
}
}
// ...and it must keep tracking the signal. A tick that renders once and then never
// changes is the same bug wearing a different hat: the row would look right until you
// clicked it.
func TestBoolPropUpdatesOnRerender(t *testing.T) {
root := document.Call("createElement", "div")
box := vdom.NewRef()
off := vdom.Input(vdom.WithRef(box), vdom.BoolProp("checked", false))
patchChildren(root, nil, one(off))
el, _ := box.Node().(js.Value)
on := vdom.Input(vdom.WithRef(box), vdom.BoolProp("checked", true))
patchChildren(root, one(off), one(on))
if !el.Get("checked").Bool() {
t.Fatal("selecting an option did not tick its checkbox")
}
back := vdom.Input(vdom.WithRef(box), vdom.BoolProp("checked", false))
patchChildren(root, one(on), one(back))
if el.Get("checked").Bool() {
t.Error("deselecting an option did not UNTICK its checkbox")
}
}
// A string prop is still a string — the boolean handling must not swallow an input's
// value, least of all the literal value "false".
func TestStringPropIsUntouched(t *testing.T) {
root := document.Call("createElement", "div")
r := vdom.NewRef()
patchChildren(root, nil, one(vdom.Input(vdom.WithRef(r), vdom.Prop("value", "false"))))
el, _ := r.Node().(js.Value)
if got := el.Get("value").String(); got != "false" {
t.Errorf("value = %q, want the string \"false\"", got)
}
}
// OverflowsX is how the multi-select decides its pills no longer fit. It compares
// scrollWidth (all the content) against clientWidth (what is visible) — NOT
// getBoundingClientRect, which reports the clipped box and so can never reveal an
// overflow.
func TestOverflowsX(t *testing.T) {
root := document.Call("createElement", "div")
cases := []struct {
name string
client, scroll, want float64
overflows bool
}{
{name: "content fits exactly", client: 200, scroll: 200},
{name: "content is wider than the box", client: 200, scroll: 340, overflows: true},
// Sub-pixel layout puts scrollWidth a hair over clientWidth on content that
// visually fits; believing that would collapse a field showing two pills.
{name: "sub-pixel noise is not an overflow", client: 200, scroll: 200.6},
// A hidden element has no layout at all. That is not an overflow — and reading it
// as one is exactly how a collapsed field would get stuck collapsed.
{name: "display:none reports nothing", client: 0, scroll: 0},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
r := vdom.NewRef()
n := vdom.Div(vdom.WithRef(r))
patchChildren(root, nil, one(n))
el, _ := r.Node().(js.Value)
el.Set("clientWidth", tc.client)
el.Set("scrollWidth", tc.scroll)
if got := OverflowsX(r); got != tc.overflows {
t.Errorf("OverflowsX(client=%v scroll=%v) = %v, want %v", tc.client, tc.scroll, got, tc.overflows)
}
patchChildren(root, one(n), nil)
})
}
}
// An unmounted ref measures nothing rather than panicking — components call the host
// API unconditionally, including on the render before their element exists.
func TestOverflowsXOnUnmountedRef(t *testing.T) {
if OverflowsX(vdom.NewRef()) {
t.Error("an unmounted ref should not report an overflow")
}
}

View File

@@ -163,7 +163,7 @@ func createDOM(n *vdom.VNode, ns string) js.Value {
el.Call("setAttribute", k, v) el.Call("setAttribute", k, v)
} }
for k, v := range n.Props { for k, v := range n.Props {
el.Set(k, v) setProp(el, k, v)
} }
for name, h := range n.Events { for name, h := range n.Events {
addListener(n, name, h) addListener(n, name, h)
@@ -316,12 +316,33 @@ func updateAttrs(o, x *vdom.VNode) {
func updateProps(o, x *vdom.VNode) { func updateProps(o, x *vdom.VNode) {
dom := rt(x).dom dom := rt(x).dom
for k, v := range x.Props { for k, v := range x.Props {
if o.Props[k] != v && dom.Get(k).String() != v { if o.Props[k] != v && !propEquals(dom, k, v) {
dom.Set(k, v) setProp(dom, k, v)
} }
} }
} }
// setProp writes a live DOM property. Props travel as strings, but the property they
// land on may be a boolean — and JS reads the string "false" as TRUE, so writing
// el.checked = "false" ticks the box. Boolean props are converted before the write.
func setProp(el js.Value, k, v string) {
if vdom.IsBoolProp(k) {
el.Set(k, v == "true")
return
}
el.Set(k, v)
}
// propEquals compares against what the DOM currently holds, in the property's own
// type. It is what lets a re-render skip touching an <input> the user is typing in.
func propEquals(el js.Value, k, v string) bool {
cur := el.Get(k)
if vdom.IsBoolProp(k) {
return cur.Truthy() == (v == "true")
}
return cur.String() == v
}
func updateEvents(o, x *vdom.VNode) { func updateEvents(o, x *vdom.VNode) {
r := rt(x) // same nodeRT as o (adopted above) r := rt(x) // same nodeRT as o (adopted above)
for name, fn := range r.jsFuncs { for name, fn := range r.jsFuncs {
@@ -441,7 +462,7 @@ func hydrateNode(dom js.Value, n *vdom.VNode) {
} }
} }
for k, v := range n.Props { for k, v := range n.Props {
dom.Set(k, v) setProp(dom, k, v)
} }
if n.HTML != "" { if n.HTML != "" {
return // trust server-rendered HTML return // trust server-rendered HTML

View File

@@ -42,6 +42,11 @@ class DNode {
// Tests set .rect to control what getBoundingClientRect reports; there is no // Tests set .rect to control what getBoundingClientRect reports; there is no
// layout engine here, so geometry is whatever the test declares. // layout engine here, so geometry is whatever the test declares.
this.rect = { left: 0, top: 0, width: 0, height: 0 }; this.rect = { left: 0, top: 0, width: 0, height: 0 };
// Likewise for overflow. clientWidth is the visible content box, scrollWidth the
// full content — content overflows exactly when the second exceeds the first, and
// a test declares both rather than a layout engine deriving them.
this.clientWidth = 0;
this.scrollWidth = 0;
} }
get nodeType() { return this.tag === "#text" ? 3 : 1; } get nodeType() { return this.tag === "#text" ? 3 : 1; }
get firstChild() { return this.childNodes[0] ?? null; } get firstChild() { return this.childNodes[0] ?? null; }

View File

@@ -815,6 +815,11 @@ func FormTimezoneSelector(p FormSelectProps) *vdom.VNode {
type dropdown struct { type dropdown struct {
f *Floating 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] search *vdom.Signal[string]
searchRef *vdom.Ref searchRef *vdom.Ref
// active is the keyboard-highlighted option, as an index into the FILTERED list. // active is the keyboard-highlighted option, as an index into the FILTERED list.
@@ -830,8 +835,9 @@ type DropdownOptions struct {
OnOpenChange func(bool) OnOpenChange func(bool)
} }
func newDropdown(o DropdownOptions, closeOnSelect bool) *dropdown { func newDropdown(o DropdownOptions, multi bool) *dropdown {
d := &dropdown{ d := &dropdown{
multi: multi,
search: vdom.NewSignal(""), search: vdom.NewSignal(""),
searchRef: vdom.NewRef(), searchRef: vdom.NewRef(),
active: vdom.NewSignal(-1), active: vdom.NewSignal(-1),
@@ -858,7 +864,6 @@ func newDropdown(o DropdownOptions, closeOnSelect bool) *dropdown {
} }
}, },
}) })
_ = closeOnSelect
return d return d
} }
@@ -963,9 +968,32 @@ func (d *dropdown) optionButton(opt FormSelectOption, idx int, selected, onDark,
} else if onClick != nil { } else if onClick != nil {
mods = append(mods, vdom.On(vdom.EVENT_CLICK, onClick)) 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))...) 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 { func (d *dropdown) noResults(onDark bool) *vdom.VNode {
cls := formDropdownNoResults cls := formDropdownNoResults
if onDark { if onDark {
@@ -1015,7 +1043,7 @@ type Combobox struct{ *dropdown }
// NewCombobox creates a single-select dropdown. // NewCombobox creates a single-select dropdown.
func NewCombobox(o DropdownOptions) *Combobox { func NewCombobox(o DropdownOptions) *Combobox {
return &Combobox{dropdown: newDropdown(o, true)} return &Combobox{dropdown: newDropdown(o, false)} // picks one — no checkboxes
} }
// FormComboboxProps configures Combobox.Render. // FormComboboxProps configures Combobox.Render.
@@ -1117,11 +1145,23 @@ func NewSearchableSelect(o DropdownOptions) *Combobox { return NewCombobox(o) }
// click, on Escape, or when its trigger is clicked again. // click, on Escape, or when its trigger is clicked again.
// //
// Create it once, alongside your signals — never inside a render function. // Create it once, alongside your signals — never inside a render function.
type MultiSelect struct{ *dropdown } 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. // NewMultiSelect creates a multi-select dropdown.
func NewMultiSelect(o DropdownOptions) *MultiSelect { func NewMultiSelect(o DropdownOptions) *MultiSelect {
return &MultiSelect{dropdown: newDropdown(o, false)} return &MultiSelect{
dropdown: newDropdown(o, true), // picks several — every row gets a checkbox
tagsRef: vdom.NewRef(),
countRef: vdom.NewRef(),
}
} }
// FormMultiSelectProps configures MultiSelect.Render. // FormMultiSelectProps configures MultiSelect.Render.
@@ -1154,43 +1194,6 @@ func (m *MultiSelect) Render(p FormMultiSelectProps) *vdom.VNode {
} }
} }
// The trigger's face: a placeholder, tags, or "N selected" once there are too many
// to show without the field growing unboundedly.
var face *vdom.VNode
switch {
case len(selected) == 0:
face = vdom.Span(vdom.Attr("class", "text-neutral-500 truncate"),
vdom.Text(pick(p.Placeholder, "Select options")))
case len(selected) > maxTags:
face = vdom.Span(vdom.Attr("class", "truncate"),
vdom.Text(strconv.Itoa(len(selected))+" selected"))
default:
tags := []vdom.Mod{vdom.Attr("class", "flex flex-wrap items-center gap-1 min-w-0")}
for _, opt := range selected {
ov := opt.Value
remove := []vdom.Mod{
vdom.Attr("type", "button"),
vdom.Attr("class", "bg-transparent border-none p-0 cursor-pointer flex items-center hover:text-neutral-900"),
vdom.Attr("aria-label", "Remove "+opt.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 tag sits INSIDE the trigger, so without this the click would
// bubble up and toggle the panel open as it removed the tag.
e.StopPropagation()
oc(formRemoveStr(cur, ov))
}))
}
tags = append(tags, vdom.Span(vdom.Attr("class", formMultiSelectTag),
vdom.Text(opt.Label),
vdom.Button(remove...),
))
}
face = vdom.Div(tags...)
}
pad := "p-2" pad := "p-2"
if p.Small { if p.Small {
pad = "p-1" pad = "p-1"
@@ -1200,11 +1203,14 @@ func (m *MultiSelect) Render(p FormMultiSelectProps) *vdom.VNode {
chevron = "chevron-up" 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{ trigger := m.f.Trigger(FloatingTriggerProps{
Class: cx(formTriggerBase, "overflow-hidden", formControlH(p.Small), pad), Class: cx(formTriggerBase, "overflow-hidden", formControlH(p.Small), pad),
AriaHasPopup: "listbox", AriaHasPopup: "listbox",
}, },
vdom.Div(vdom.Attr("class", "flex-1 overflow-hidden flex items-center min-w-0"), face), vdom.Div(face...),
IconInline(chevron, 16, "text-neutral-400"), IconInline(chevron, 16, "text-neutral-400"),
) )
if p.Disabled { if p.Disabled {
@@ -1270,7 +1276,121 @@ func (m *MultiSelect) Render(p FormMultiSelectProps) *vdom.VNode {
) )
} }
const formMultiSelectTag = "inline-flex items-center gap-1 rounded-default bg-neutral-100 px-1.5 py-0.5 text-xs text-neutral-700 max-w-full" // 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-neutral-500 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-neutral-900"),
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...),
))
}
count := vdom.Span(
vdom.WithRef(m.countRef),
vdom.Attr("class", faceCls("truncate", !tooMany)),
vdom.Text(itemsSelected(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-neutral-200 py-0.5 text-neutral-700 whitespace-nowrap leading-none"
func truncateRunes(s string, max int) string { func truncateRunes(s string, max int) string {
if max <= 0 { if max <= 0 {

View File

@@ -1,6 +1,7 @@
package webui package webui
import ( import (
"strconv"
"strings" "strings"
"testing" "testing"
@@ -183,6 +184,174 @@ func TestDropdownsAreStandalone(t *testing.T) {
} }
} }
// ---- the panel rows ----
// A multi-select row shows a CHECKBOX. Its job is to show a state you toggle, not an
// item you pick, and the tick is the only thing that says which is which.
//
// The unified dropdown dropped it: both kinds of row rendered as a bare label, so a
// multi-select gave no indication of what was already selected.
func TestMultiSelectOptionsHaveCheckboxes(t *testing.T) {
m := NewMultiSelect(DropdownOptions{})
m.Open()
node := m.Render(FormMultiSelectProps{
Options: dropdownOptions(),
Value: []string{"res"}, // Research is selected; the other two are not
})
for _, want := range []struct {
label string
checked bool
}{
{"Engineering", false},
{"Research", true},
{"Networking", false},
} {
box := findCheckboxIn(optionRow(t, node, want.label))
if box == nil {
t.Errorf("option %q has no checkbox", want.label)
continue
}
if got := box.Props["checked"]; got != strconv.FormatBool(want.checked) {
t.Errorf("option %q: checked=%q, want %v", want.label, got, want.checked)
}
}
}
// ...and a combobox row does NOT. It picks one thing; a checkbox would promise a
// multiple selection it will not honour.
func TestComboboxOptionsHaveNoCheckboxes(t *testing.T) {
c := NewCombobox(DropdownOptions{})
c.Open()
node := c.Render(FormComboboxProps{Options: dropdownOptions(), Value: "res"})
if findCheckboxIn(optionRow(t, node, "Research")) != nil {
t.Error("a single-select option must not render a checkbox")
}
}
// ---- the collapsed trigger ----
// Past maxTagsBeforeCollapse the field stops showing pills and says how many are
// selected. Without this the trigger either grows without bound or silently clips the
// selection, and the user cannot tell how much they picked.
func TestMultiSelectCollapsesToACount(t *testing.T) {
m := NewMultiSelect(DropdownOptions{})
html := vdom.RenderHTML(m.Render(FormMultiSelectProps{
Options: manyOptions(6),
Value: []string{"o1", "o2", "o3", "o4"}, // 4 > the default max of 3
MaxTagsBeforeCollapse: 3,
}))
if !strings.Contains(html, "4 items selected") {
t.Errorf("collapsed trigger does not say how many are selected:\n%s", html)
}
// The count is the SERVER's choice too — this needs no measurement, so SSR must not
// ship a row of clipped pills that the client then has to swap out.
if !strings.Contains(html, `class="flex flex-nowrap items-center gap-1 min-w-0 overflow-hidden hidden"`) {
t.Errorf("the pills were not hidden server-side:\n%s", html)
}
}
// One is one ITEM, not one items.
func TestCollapsedCountIsPluralised(t *testing.T) {
if got := itemsSelected(1); got != "1 item selected" {
t.Errorf("itemsSelected(1) = %q", got)
}
if got := itemsSelected(4); got != "4 items selected" {
t.Errorf("itemsSelected(4) = %q", got)
}
}
// Under the limit, the pills show and the summary is the hidden one. Both are always in
// the DOM: which is visible is settled by a measurement after render (collapseFace),
// and that measurement needs something to measure.
func TestMultiSelectShowsPillsWhenTheyFit(t *testing.T) {
m := NewMultiSelect(DropdownOptions{})
html := vdom.RenderHTML(m.Render(FormMultiSelectProps{
Options: dropdownOptions(),
Value: []string{"eng", "res"},
MaxTagsBeforeCollapse: 3,
}))
if !strings.Contains(html, "Engineering") || !strings.Contains(html, "Research") {
t.Errorf("the selected pills are missing:\n%s", html)
}
if !strings.Contains(html, `class="truncate hidden"`) {
t.Errorf("the count summary should be the hidden face here:\n%s", html)
}
// It must be RENDERED though, or there is nothing for collapseFace to reveal.
if !strings.Contains(html, "2 items selected") {
t.Errorf("the count summary was not rendered at all:\n%s", html)
}
}
// The pills must lay out on ONE line and clip. With flex-wrap they wrap instead, so
// scrollWidth never exceeds clientWidth, and the overflow collapse can never fire.
func TestPillsAreNowrapSoOverflowIsMeasurable(t *testing.T) {
m := NewMultiSelect(DropdownOptions{})
html := vdom.RenderHTML(m.Render(FormMultiSelectProps{
Options: dropdownOptions(),
Value: []string{"eng"},
}))
if strings.Contains(html, "flex-wrap") {
t.Error("the pill row wraps; overflow would never be detected")
}
if !strings.Contains(html, "flex-nowrap") || !strings.Contains(html, "overflow-hidden") {
t.Errorf("the pill row must be flex-nowrap + overflow-hidden:\n%s", html)
}
}
func manyOptions(n int) []FormSelectOption {
out := make([]FormSelectOption, n)
for i := range out {
id := strconv.Itoa(i + 1)
out[i] = FormSelectOption{Value: "o" + id, Label: "Option " + id}
}
return out
}
// optionRow returns the panel row whose label is label.
func optionRow(t *testing.T, n *vdom.VNode, label string) *vdom.VNode {
t.Helper()
if row := findRow(n, label); row != nil {
return row
}
t.Fatalf("no option row labelled %q", label)
return nil
}
func findRow(n *vdom.VNode, label string) *vdom.VNode {
if n == nil {
return nil
}
if n.Tag == "button" && n.Attrs["role"] == "option" && nodeText(n) == label {
return n
}
for _, c := range n.Children {
if row := findRow(c, label); row != nil {
return row
}
}
return nil
}
func findCheckboxIn(n *vdom.VNode) *vdom.VNode {
if n == nil {
return nil
}
if n.Tag == "input" && n.Attrs["type"] == "checkbox" {
return n
}
for _, c := range n.Children {
if box := findCheckboxIn(c); box != nil {
return box
}
}
return nil
}
// clickOption finds an option button by its label and invokes its click handler. // clickOption finds an option button by its label and invokes its click handler.
func clickOption(t *testing.T, n *vdom.VNode, label string) { func clickOption(t *testing.T, n *vdom.VNode, label string) {
t.Helper() t.Helper()