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

@@ -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() }

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) ----
// 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)
}
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

View File

@@ -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; }