Files
kjol/go/vdom/vnode.go

181 lines
5.6 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"
"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} }
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)
writeAttrs(b, n.Props) // props like input 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) {
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('"')
}
}