245 lines
7.9 KiB
Go
245 lines
7.9 KiB
Go
// Package vdom is a platform-neutral virtual DOM shared by the server (renders
|
|
// to an HTML string) and the client (reconciles into the real DOM). It compiles
|
|
// on BOTH native and js/wasm, so the same component code runs in both places —
|
|
// which is what makes server-side rendering + client hydration possible.
|
|
package vdom
|
|
|
|
import (
|
|
"html"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// Event is a DOM event passed to handlers. The client provides a concrete
|
|
// implementation; on the server events are never invoked, and every accessor
|
|
// returns its zero value.
|
|
type Event interface {
|
|
PreventDefault()
|
|
StopPropagation()
|
|
Value() string // target.value (for inputs)
|
|
Checked() bool // target.checked (for checkboxes / radios)
|
|
Key() string // KeyboardEvent.key — compare against the KEY_* constants
|
|
ClientX() int // pointer position, viewport coordinates
|
|
ClientY() int //
|
|
Target() any // the platform DOM handle of event.target; nil on the server
|
|
SetData(format, data string) // DataTransfer — no-ops off a drag event
|
|
GetData(format string) string
|
|
}
|
|
|
|
// VNode is a virtual DOM node. Tag == "" is a text node (content in Text). HTML,
|
|
// if set on an element, is raw innerHTML (children ignored). Tag == TagPortal
|
|
// renders its children into document.body instead of in place.
|
|
type VNode struct {
|
|
Tag string
|
|
Text string
|
|
HTML string
|
|
Attrs map[string]string
|
|
Props map[string]string
|
|
Events map[string]func(Event)
|
|
Children []*VNode
|
|
|
|
// Ref, if set, receives the DOM node this VNode renders to (see WithRef).
|
|
Ref *Ref
|
|
|
|
// Runtime holds the wasm reconciler's per-node bookkeeping (DOM handle,
|
|
// listener wrappers). It's `any` so this package stays platform-neutral; it
|
|
// is nil on the server.
|
|
Runtime any
|
|
}
|
|
|
|
// Mod configures a VNode while it is built.
|
|
type Mod interface{ apply(*VNode) }
|
|
|
|
// TagPortal marks a node whose children are mounted into document.body rather
|
|
// than into its own parent. It is how a floating panel escapes an ancestor's
|
|
// `overflow: hidden` (menus scroll, modals clip) or `transform` (which would
|
|
// re-root `position: fixed` onto the modal instead of the viewport). In the tree
|
|
// it occupies a hidden, zero-size placeholder; the children live at body level.
|
|
const TagPortal = "#portal"
|
|
|
|
// El builds an element VNode.
|
|
func El(tag string, mods ...Mod) *VNode {
|
|
n := &VNode{Tag: tag, Attrs: map[string]string{}, Props: map[string]string{}, Events: map[string]func(Event){}}
|
|
for _, m := range mods {
|
|
m.apply(n)
|
|
}
|
|
return n
|
|
}
|
|
|
|
// Portal renders its children into document.body. On the server it renders the
|
|
// placeholder only — floating content is closed during SSR, so there is nothing
|
|
// to emit, and the placeholder keeps the client's hydration walk aligned.
|
|
func Portal(children ...*VNode) *VNode {
|
|
n := El(TagPortal)
|
|
for _, c := range children {
|
|
c.apply(n)
|
|
}
|
|
return n
|
|
}
|
|
|
|
// Text builds a text VNode.
|
|
func Text(s string) *VNode { return &VNode{Text: s} }
|
|
|
|
func (n *VNode) apply(parent *VNode) {
|
|
if n != nil { // a nil child renders nothing (e.g. a closed Modal/Menu returns nil)
|
|
parent.Children = append(parent.Children, n)
|
|
}
|
|
}
|
|
|
|
type attrMod struct{ k, v string }
|
|
|
|
func (a attrMod) apply(n *VNode) { n.Attrs[a.k] = a.v }
|
|
|
|
// Attr sets an HTML attribute.
|
|
func Attr(k, v string) Mod { return attrMod{k, v} }
|
|
|
|
type propMod struct{ k, v string }
|
|
|
|
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 }
|
|
|
|
// Raw sets inner HTML verbatim (children ignored) — e.g. a server-computed SVG.
|
|
func Raw(html string) Mod { return htmlMod{html} }
|
|
|
|
type eventMod struct {
|
|
name string
|
|
h func(Event)
|
|
}
|
|
|
|
func (e eventMod) apply(n *VNode) { n.Events[e.name] = e.h }
|
|
|
|
// On registers an event handler that ignores the event object.
|
|
func On(event string, h func()) Mod { return eventMod{event, func(Event) { h() }} }
|
|
|
|
// OnEvent registers an event handler that receives the Event.
|
|
func OnEvent(event string, h func(Event)) Mod { return eventMod{event, h} }
|
|
|
|
// --- server-side HTML rendering (used for SSR; runs on any platform) ---
|
|
|
|
var voidTags = map[string]bool{"br": true, "hr": true, "img": true, "input": true, "meta": true, "link": true}
|
|
|
|
// RenderHTML serializes a VNode tree to HTML with NO extra whitespace, so the
|
|
// browser's parsed childNodes line up 1:1 with the VNode children on hydration.
|
|
func RenderHTML(n *VNode) string {
|
|
var b strings.Builder
|
|
writeNode(&b, n)
|
|
return b.String()
|
|
}
|
|
|
|
func writeNode(b *strings.Builder, n *VNode) {
|
|
if n.Tag == "" {
|
|
b.WriteString(html.EscapeString(n.Text))
|
|
return
|
|
}
|
|
if n.Tag == TagPortal {
|
|
// Placeholder only — the children belong to document.body, which SSR does
|
|
// not own. Emitting the same element the client creates keeps hydration's
|
|
// childNodes lined up.
|
|
b.WriteString(`<div data-portal="" style="display:none"></div>`)
|
|
return
|
|
}
|
|
b.WriteByte('<')
|
|
b.WriteString(n.Tag)
|
|
writeAttrs(b, n.Attrs)
|
|
writeProps(b, n.Props) // props like an input's value show up as attributes in SSR
|
|
b.WriteByte('>')
|
|
if voidTags[n.Tag] {
|
|
return
|
|
}
|
|
if n.HTML != "" {
|
|
b.WriteString(n.HTML) // raw
|
|
} else {
|
|
for _, c := range n.Children {
|
|
writeNode(b, c)
|
|
}
|
|
}
|
|
b.WriteString("</")
|
|
b.WriteString(n.Tag)
|
|
b.WriteByte('>')
|
|
}
|
|
|
|
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)
|
|
return keys
|
|
}
|