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