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

@@ -815,6 +815,11 @@ func FormTimezoneSelector(p FormSelectProps) *vdom.VNode {
type dropdown struct {
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]
searchRef *vdom.Ref
// active is the keyboard-highlighted option, as an index into the FILTERED list.
@@ -830,8 +835,9 @@ type DropdownOptions struct {
OnOpenChange func(bool)
}
func newDropdown(o DropdownOptions, closeOnSelect bool) *dropdown {
func newDropdown(o DropdownOptions, multi bool) *dropdown {
d := &dropdown{
multi: multi,
search: vdom.NewSignal(""),
searchRef: vdom.NewRef(),
active: vdom.NewSignal(-1),
@@ -858,7 +864,6 @@ func newDropdown(o DropdownOptions, closeOnSelect bool) *dropdown {
}
},
})
_ = closeOnSelect
return d
}
@@ -963,9 +968,32 @@ func (d *dropdown) optionButton(opt FormSelectOption, idx int, selected, onDark,
} else if onClick != nil {
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))...)
}
// 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 {
cls := formDropdownNoResults
if onDark {
@@ -1015,7 +1043,7 @@ type Combobox struct{ *dropdown }
// NewCombobox creates a single-select dropdown.
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.
@@ -1117,11 +1145,23 @@ func NewSearchableSelect(o DropdownOptions) *Combobox { return NewCombobox(o) }
// click, on Escape, or when its trigger is clicked again.
//
// 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.
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.
@@ -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"
if p.Small {
pad = "p-1"
@@ -1200,11 +1203,14 @@ func (m *MultiSelect) Render(p FormMultiSelectProps) *vdom.VNode {
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{
Class: cx(formTriggerBase, "overflow-hidden", formControlH(p.Small), pad),
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"),
)
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 {
if max <= 0 {