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

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