fix multiselect combobox
This commit is contained in:
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user