fix multiselect combobox
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
. "kjol/vdom"
|
||||
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
|
||||
func KitPage(d Deps) func() *VNode {
|
||||
// Interactive demos own their state via signals (a write re-renders).
|
||||
@@ -41,6 +57,7 @@ func KitPage(d Deps) func() *VNode {
|
||||
name := NewSignal("")
|
||||
email := NewSignal("")
|
||||
plan := NewSignal("pro")
|
||||
langs := NewSignal([]string{"go"})
|
||||
|
||||
// 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
|
||||
@@ -48,6 +65,7 @@ func KitPage(d Deps) func() *VNode {
|
||||
modal := ui.NewModal(ui.ModalOptions{Size: ui.ModalMedium})
|
||||
menu := ui.NewMenu(ui.MenuOptions{Placement: ui.PlacementBottomStart})
|
||||
tip := ui.NewHoverTooltip(ui.PlacementTop, "")
|
||||
skills := ui.NewMultiSelect(ui.DropdownOptions{})
|
||||
|
||||
return func() *VNode {
|
||||
return Div(Attr("class", "space-y-8"),
|
||||
@@ -134,9 +152,23 @@ func KitPage(d Deps) func() *VNode {
|
||||
ui.FormOption("free", "Free", false),
|
||||
ui.FormOption("pro", "Pro", 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"),
|
||||
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",
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -7,6 +7,7 @@ package vdom
|
||||
import (
|
||||
"html"
|
||||
"sort"
|
||||
"strconv"
|
||||
"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).
|
||||
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 }
|
||||
|
||||
func (h htmlMod) apply(n *VNode) { n.HTML = h.html }
|
||||
@@ -147,7 +183,7 @@ func writeNode(b *strings.Builder, n *VNode) {
|
||||
b.WriteByte('<')
|
||||
b.WriteString(n.Tag)
|
||||
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('>')
|
||||
if voidTags[n.Tag] {
|
||||
return
|
||||
@@ -165,16 +201,44 @@ func writeNode(b *strings.Builder, n *VNode) {
|
||||
}
|
||||
|
||||
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))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, k := range keys {
|
||||
b.WriteByte(' ')
|
||||
b.WriteString(k)
|
||||
b.WriteString(`="`)
|
||||
b.WriteString(html.EscapeString(m[k]))
|
||||
b.WriteByte('"')
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
46
go/vdom/vnode_test.go
Normal file
46
go/vdom/vnode_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,11 @@ func ScrollIntoView(*vdom.Ref, bool, string) {}
|
||||
func ScrollLeft(*vdom.Ref) float64 { return 0 }
|
||||
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 ClosestAttr(any, string, string) (string, bool) { return "", false }
|
||||
func QuerySelector(string) *vdom.Ref { return vdom.NewRef() }
|
||||
|
||||
@@ -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) ----
|
||||
|
||||
// Contains reports whether target lies inside r's subtree. target is an
|
||||
|
||||
120
go/wasmruntime/props_test.go
Normal file
120
go/wasmruntime/props_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -163,7 +163,7 @@ func createDOM(n *vdom.VNode, ns string) js.Value {
|
||||
el.Call("setAttribute", k, v)
|
||||
}
|
||||
for k, v := range n.Props {
|
||||
el.Set(k, v)
|
||||
setProp(el, k, v)
|
||||
}
|
||||
for name, h := range n.Events {
|
||||
addListener(n, name, h)
|
||||
@@ -316,12 +316,33 @@ func updateAttrs(o, x *vdom.VNode) {
|
||||
func updateProps(o, x *vdom.VNode) {
|
||||
dom := rt(x).dom
|
||||
for k, v := range x.Props {
|
||||
if o.Props[k] != v && dom.Get(k).String() != v {
|
||||
dom.Set(k, v)
|
||||
if o.Props[k] != v && !propEquals(dom, 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) {
|
||||
r := rt(x) // same nodeRT as o (adopted above)
|
||||
for name, fn := range r.jsFuncs {
|
||||
@@ -441,7 +462,7 @@ func hydrateNode(dom js.Value, n *vdom.VNode) {
|
||||
}
|
||||
}
|
||||
for k, v := range n.Props {
|
||||
dom.Set(k, v)
|
||||
setProp(dom, k, v)
|
||||
}
|
||||
if n.HTML != "" {
|
||||
return // trust server-rendered HTML
|
||||
|
||||
5
go/wasmruntime/testdata/domexec.js
vendored
5
go/wasmruntime/testdata/domexec.js
vendored
@@ -42,6 +42,11 @@ class DNode {
|
||||
// Tests set .rect to control what getBoundingClientRect reports; there is no
|
||||
// layout engine here, so geometry is whatever the test declares.
|
||||
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 firstChild() { return this.childNodes[0] ?? null; }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"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.
|
||||
func clickOption(t *testing.T, n *vdom.VNode, label string) {
|
||||
t.Helper()
|
||||
|
||||
Reference in New Issue
Block a user