StyleInline converts a map of CSS properties into an inline style string.
Keys and values are concatenated as "key:value" pairs separated by semicolons.
{
var b strings.Builder
first := true
for k, v := range styles {
if !first {
b.WriteByte(';')
}
first = false
b.WriteString(k)
b.WriteByte(':')
b.WriteString(v)
}
return b.String()
}
BindStoreInputsForComponent is a no-op outside wasm builds.
{}
BindStoreInputs is a no-op outside wasm builds.
{}
SnapshotComponentSignals is a stub returning nil outside wasm builds.
{ return nil }
Event wraps a browser event.
PreventDefault prevents the default action for the event.
{ e.Call("preventDefault") }
StopPropagation stops the event from bubbling.
{ e.Call("stopPropagation") }
VDOMNode represents a node in a virtual DOM tree derived from an HTML
template.
NewVDOM parses an HTML template into a virtual DOM tree.
{
reader := strings.NewReader(htmlTemplate)
doc, err := html.Parse(reader)
if err != nil {
return nil, err
}
root := mapHTMLNode(doc)
return root, nil
}
{
if n.Type == html.TextNode {
return &VDOMNode{
Text: n.Data,
}
}
if n.Type == html.DocumentNode || n.Type == html.ElementNode {
node := &VDOMNode{
Tag: n.Data,
Attributes: mapAttributes(n),
}
for child := n.FirstChild; child != nil; child = child.NextSibling {
node.Children = append(node.Children, mapHTMLNode(child))
}
return node
}
return nil
}
{
attrs := make(map[string]string)
for _, attr := range n.Attr {
attrs[attr.Key] = attr.Val
}
return attrs
}
{
if node == nil {
return
}
if node.Tag != "" {
fmt.Printf("%s<Tag: %s, Attributes: %v>\n", indent, node.Tag, node.Attributes)
}
if node.Text != "" {
fmt.Printf("%s<Text: %s>\n", indent, node.Text)
}
for _, child := range node.Children {
printVDOM(child, indent+" ")
}
}
{
mods := make(map[string]bool)
if attr == "" {
return mods
}
for _, m := range strings.Split(attr, ",") {
m = strings.TrimSpace(m)
if m != "" {
mods[m] = true
}
}
return mods
}
{
node := root
for _, idx := range path {
children := node.Get("children")
if idx >= children.Length() {
return js.Null()
}
node = children.Index(idx)
}
return node
}
BindEventListeners attaches registered handlers to nodes within the component
identified by componentID.
{
if bs, ok := compiledBindings[componentID]; ok {
for _, b := range bs {
node := nodeByPath(root, b.Path)
handler := GetHandler(b.Handler)
if !handler.Truthy() {
continue
}
mods := make(map[string]bool)
for _, m := range b.Modifiers {
mods[m] = true
}
wrapped := js.FuncOf(func(this js.Value, args []js.Value) any {
if mods["stopPropagation"] && len(args) > 0 {
args[0].Call("stopPropagation")
}
if mods["preventDefault"] && len(args) > 0 {
args[0].Call("preventDefault")
}
anyArgs := make([]any, len(args))
for i, a := range args {
anyArgs[i] = a
}
handler.Invoke(anyArgs...)
return nil
})
if mods["once"] {
opts := js.NewDict()
opts.Set("once", true)
node.Call("addEventListener", b.Event, wrapped, opts.Value)
} else {
node.Call("addEventListener", b.Event, wrapped)
}
listeners[componentID] = append(listeners[componentID], eventListener{node, b.Event, wrapped})
}
return
}
nodes := root.Call("querySelectorAll", "*")
for i := 0; i < nodes.Length(); i++ {
node := nodes.Index(i)
attrs := node.Call("getAttributeNames")
for j := 0; j < attrs.Length(); j++ {
name := attrs.Index(j).String()
if strings.HasPrefix(name, "data-on-") && !strings.HasSuffix(name, "-modifiers") {
event := strings.TrimPrefix(name, "data-on-")
handlerName := node.Call("getAttribute", name).String()
modsAttr := node.Call("getAttribute", fmt.Sprintf("data-on-%s-modifiers", event)).String()
modifiers := parseModifiers(modsAttr)
handler := GetHandler(handlerName)
if handler.Truthy() {
wrapped := js.FuncOf(func(this js.Value, args []js.Value) any {
if modifiers["stopPropagation"] && len(args) > 0 {
args[0].Call("stopPropagation")
}
if modifiers["preventDefault"] && len(args) > 0 {
args[0].Call("preventDefault")
}
anyArgs := make([]any, len(args))
for i, a := range args {
anyArgs[i] = a
}
handler.Invoke(anyArgs...)
return nil
})
if modifiers["once"] {
opts := js.NewDict()
opts.Set("once", true)
node.Call("addEventListener", event, wrapped, opts.Value)
} else {
node.Call("addEventListener", event, wrapped)
}
listeners[componentID] = append(listeners[componentID], eventListener{node, event, wrapped})
}
}
}
}
}
RemoveEventListeners detaches all event listeners associated with the
specified component.
{
if ls, ok := listeners[componentID]; ok {
for _, l := range ls {
l.element.Call("removeEventListener", l.event, l.handler)
l.handler.Release()
}
delete(listeners, componentID)
}
delete(compiledBindings, componentID)
}
CreateElement returns a new element with the given tag name.
{ return Doc().CreateElement(tag) }
ByID fetches an element by its id attribute.
{ return Doc().ByID(id) }
Query returns the first element matching the CSS selector.
{ return Doc().Query(selector) }
QueryAll returns all elements matching the CSS selector.
{ return Doc().QueryAll(selector) }
ByClass returns all elements with the given class name.
{ return Doc().ByClass(name) }
ByTag returns all elements with the given tag name.
{ return Doc().ByTag(tag) }
SetInnerHTML replaces an element's children with the provided HTML string.
{ el.SetHTML(html) }
Text returns an element's text content.
{ return el.Text() }
SetText sets an element's text content.
{ el.SetText(text) }
Attr retrieves the value of an attribute or an empty string if unset.
{ return el.Attr(name) }
SetAttr sets the value of an attribute on the element.
{ el.SetAttr(name, value) }
AddClass adds a class to the element's class list.
{ el.AddClass(class) }
RemoveClass removes a class from the element's class list.
{ el.RemoveClass(class) }
HasClass reports whether the element has the specified class.
{ return el.HasClass(class) }
ToggleClass toggles the presence of a class on the element's class list.
{ el.ToggleClass(class) }
SetStyle sets an inline style property on the element.
{ el.SetStyle(prop, value) }
binding represents a precompiled event binding.
RegisterBindings generates and associates bindings for a component instance.
{
if bs, ok := precompiledByName[name]; ok {
compiledBindings[id] = bs
return
}
bs, err := parseTemplate(template)
if err != nil {
return
}
precompiledByName[name] = bs
compiledBindings[id] = bs
}
OverrideBindings replaces the cached bindings for a component name.
{
bs, err := parseTemplate(template)
if err != nil {
return
}
precompiledByName[name] = bs
}
{
processed := replaceEventHandlers(tpl)
node, err := html.Parse(strings.NewReader(processed))
if err != nil {
return nil, err
}
return collectBindings(node, nil), nil
}
{
var res []binding
if n.Type == html.ElementNode {
attrs := map[string]string{}
for _, a := range n.Attr {
attrs[a.Key] = a.Val
}
for k, v := range attrs {
if strings.HasPrefix(k, "data-on-") && !strings.HasSuffix(k, "-modifiers") {
event := strings.TrimPrefix(k, "data-on-")
mods := []string{}
if m, ok := attrs[fmt.Sprintf("data-on-%s-modifiers", event)]; ok && m != "" {
for _, s := range strings.Split(m, ",") {
s = strings.TrimSpace(s)
if s != "" {
mods = append(mods, s)
}
}
}
res = append(res, binding{Path: append([]int(nil), path...), Event: event, Handler: v, Modifiers: mods})
}
}
}
child := n.FirstChild
idx := 0
for child != nil {
res = append(res, collectBindings(child, append(path, idx))...)
child = child.NextSibling
idx++
}
return res
}
{
return eventRegex.ReplaceAllStringFunc(template, func(match string) string {
parts := eventRegex.FindStringSubmatch(match)
if len(parts) != 5 {
return match
}
fullEvent := parts[2]
handler := parts[3]
suffix := parts[4]
eventParts := strings.Split(fullEvent, ".")
event := eventParts[0]
modifiers := []string{}
if len(eventParts) > 1 {
modifiers = eventParts[1:]
}
attr := fmt.Sprintf("data-on-%s=\"%s\"", event, handler)
if len(modifiers) > 0 {
attr += fmt.Sprintf(" data-on-%s-modifiers=\"%s\"", event, strings.Join(modifiers, ","))
}
return attr + suffix
})
}
Document wraps the global document object.
ByID fetches an element by id.
{
return Element{d.Call("getElementById", id)}
}
Query returns the first element matching the selector.
{
return Element{d.Call("querySelector", sel)}
}
QueryAll returns all elements matching the selector.
{
return Element{d.Call("querySelectorAll", sel)}
}
ByClass returns all elements with the given class name.
{
return Element{d.Call("getElementsByClassName", name)}
}
ByTag returns all elements with the given tag name.
{
return Element{d.Call("getElementsByTagName", tag)}
}
CreateElement creates a new element with the tag.
{
return Element{d.Call("createElement", tag)}
}
Head returns the document's
element.{ return Element{d.Get("head")} }
Body returns the document's
element.{ return Element{d.Get("body")} }
Doc returns the global Document.
{ return Document{js.Doc()} }
RegisterSignal associates a signal with a component so inputs can bind to it.
{
componentSignalsMu.Lock()
if componentSignals[componentID] == nil {
componentSignals[componentID] = make(map[string]any)
}
componentSignals[componentID][name] = sig
componentSignalsMu.Unlock()
}
RemoveComponentSignals cleans up signals for a component on unmount.
{
componentSignalsMu.Lock()
delete(componentSignals, componentID)
componentSignalsMu.Unlock()
}
{
componentSignalsMu.RLock()
defer componentSignalsMu.RUnlock()
if m, ok := componentSignals[componentID]; ok {
return m[name]
}
return nil
}
SnapshotComponentSignals returns a copy of the signals registered for a component.
{
componentSignalsMu.RLock()
defer componentSignalsMu.RUnlock()
if signals, ok := componentSignals[componentID]; ok {
clone := make(map[string]any, len(signals))
for k, v := range signals {
clone[k] = v
}
return clone
}
return nil
}
UpdateDOM patches the DOM of the specified component with the provided
HTML string, resolving the target via typed Document/Element wrappers.
{
doc := Doc()
var element Element
if componentID == "" {
element = doc.ByID("app")
} else {
element = doc.Query(fmt.Sprintf("[data-component-id='%s']", componentID))
if element.IsNull() || element.IsUndefined() {
element = doc.ByID("app")
}
}
if element.IsNull() || element.IsUndefined() {
return
}
RemoveEventListeners(componentID)
if strings.HasPrefix(html, "<root") && strings.EqualFold(element.Get("nodeName").String(), "ROOT") {
if end := strings.LastIndex(html, "</root>"); end != -1 {
if start := strings.Index(html, ">"); start != -1 {
html = html[start+1 : end]
}
}
}
patchInnerHTML(element.Value, html)
if TemplateHook != nil {
TemplateHook(componentID, html)
}
BindStoreInputsForComponent(componentID, element.Value)
BindSignalInputs(componentID, element.Value)
BindEventListeners(componentID, element.Value)
}
BindStoreInputsForComponent binds input elements to store variables while
providing the component context for runtime hooks.
{
inputs := element.Call("querySelectorAll", "input, select, textarea")
for i := 0; i < inputs.Length(); i++ {
input := inputs.Index(i)
valueAttr := input.Get("value").String()
checkedAttr := ""
if input.Call("hasAttribute", "checked").Bool() {
checkedAttr = input.Call("getAttribute", "checked").String()
}
re := regexp.MustCompile(`@store:(\w+)\.(\w+)\.(\w+):w`)
valueMatch := re.FindStringSubmatch(valueAttr)
checkedMatch := re.FindStringSubmatch(checkedAttr)
var module, storeName, key string
var usesChecked bool
if len(valueMatch) == 4 {
module, storeName, key = valueMatch[1], valueMatch[2], valueMatch[3]
} else if len(checkedMatch) == 4 {
module, storeName, key = checkedMatch[1], checkedMatch[2], checkedMatch[3]
usesChecked = true
} else {
continue
}
store := state.GlobalStoreManager.GetStore(module, storeName)
if store == nil {
continue
}
if StoreBindingHook != nil && componentID != "" {
StoreBindingHook(componentID, module, storeName, key)
}
storeValue := store.Get(key)
if usesChecked {
boolVal, _ := storeValue.(bool)
input.Set("checked", boolVal)
ch := events.Listen("change", input)
go func(in js.Value, st *state.Store, k string) {
for range ch {
st.Set(k, in.Get("checked").Bool())
}
}(input, store, key)
continue
}
if storeValue == nil {
storeValue = ""
}
input.Set("value", fmt.Sprintf("%v", storeValue))
ch := events.Listen("input", input)
go func(in js.Value, st *state.Store, k string) {
for range ch {
st.Set(k, in.Get("value").String())
}
}(input, store, key)
}
}
BindStoreInputs binds input elements to store variables.
{
BindStoreInputsForComponent("", element)
}
BindSignalInputs binds input elements to local component signals.
{
inputs := element.Call("querySelectorAll", "input, select, textarea")
for i := 0; i < inputs.Length(); i++ {
input := inputs.Index(i)
valueAttr := input.Get("value").String()
checkedAttr := ""
if input.Call("hasAttribute", "checked").Bool() {
checkedAttr = input.Call("getAttribute", "checked").String()
}
re := regexp.MustCompile(`@signal:(\w+):w`)
valueMatch := re.FindStringSubmatch(valueAttr)
checkedMatch := re.FindStringSubmatch(checkedAttr)
var name string
var usesChecked bool
if len(valueMatch) == 2 {
name = valueMatch[1]
} else if len(checkedMatch) == 2 {
name = checkedMatch[1]
usesChecked = true
} else {
continue
}
sig := getSignal(componentID, name)
if sig == nil {
continue
}
if usesChecked {
if s, ok := sig.(interface {
Read() any
Set(bool)
}); ok {
if b, ok := s.Read().(bool); ok {
input.Set("checked", b)
}
ch := events.Listen("change", input)
go func(in js.Value, sg interface{ Set(bool) }) {
for range ch {
sg.Set(in.Get("checked").Bool())
}
}(input, s)
}
continue
}
if s, ok := sig.(interface {
Read() any
Set(string)
}); ok {
input.Set("value", fmt.Sprintf("%v", s.Read()))
ch := events.Listen("input", input)
go func(in js.Value, sg interface{ Set(string) }) {
for range ch {
sg.Set(in.Get("value").String())
}
}(input, s)
}
}
}
{
template := CreateElement("template")
template.Set("innerHTML", html)
newContent := template.Get("content")
patchChildren(element, newContent)
}
{
oldChildren := oldParent.Get("childNodes")
newChildren := newParent.Get("childNodes")
keyed := make(map[string]js.Value)
for i := 0; i < oldChildren.Length(); i++ {
child := oldChildren.Index(i)
if key := getDataKey(child); key != "" {
keyed[key] = child
}
}
index := 0
for i := 0; i < newChildren.Length(); i++ {
newChild := newChildren.Index(i)
key := getDataKey(newChild)
if key != "" {
if oldChild, ok := keyed[key]; ok {
patchNode(oldChild, newChild)
ref := oldParent.Get("childNodes").Index(index)
if !oldChild.Equal(ref) {
if ref.Truthy() {
oldParent.Call("insertBefore", oldChild, ref)
} else {
oldParent.Call("appendChild", oldChild)
}
}
delete(keyed, key)
} else {
clone := newChild.Call("cloneNode", true)
ref := oldParent.Get("childNodes").Index(index)
if ref.Truthy() {
oldParent.Call("insertBefore", clone, ref)
} else {
oldParent.Call("appendChild", clone)
}
}
index++
continue
}
oldChild := oldParent.Get("childNodes").Index(index)
if oldChild.Truthy() && getDataKey(oldChild) == "" {
patchNode(oldChild, newChild)
} else {
clone := newChild.Call("cloneNode", true)
ref := oldParent.Get("childNodes").Index(index)
if ref.Truthy() {
oldParent.Call("insertBefore", clone, ref)
} else {
oldParent.Call("appendChild", clone)
}
}
index++
}
for _, child := range keyed {
child.Call("remove")
}
for oldParent.Get("childNodes").Length() > index {
oldParent.Get("childNodes").Index(index).Call("remove")
}
}
{
if node.Get("nodeType").Int() != 1 {
return ""
}
key := node.Call("getAttribute", "data-key")
if key.Truthy() {
return key.String()
}
return ""
}
{
nodeType := newNode.Get("nodeType").Int()
if nodeType == 3 { // Text node
if oldNode.Get("nodeValue").String() != newNode.Get("nodeValue").String() {
oldNode.Set("nodeValue", newNode.Get("nodeValue"))
}
return
}
if oldNode.Get("nodeName").String() != newNode.Get("nodeName").String() {
oldNode.Call("replaceWith", newNode.Call("cloneNode", true))
return
}
if nodeType == 1 { // Element node
patchAttributes(oldNode, newNode)
}
patchChildren(oldNode, newNode)
}
{
oldAttrs := oldNode.Call("getAttributeNames")
for i := 0; i < oldAttrs.Length(); i++ {
name := oldAttrs.Index(i).String()
if !newNode.Call("hasAttribute", name).Bool() {
oldNode.Call("removeAttribute", name)
}
}
newAttrs := newNode.Call("getAttributeNames")
for i := 0; i < newAttrs.Length(); i++ {
name := newAttrs.Index(i).String()
val := newNode.Call("getAttribute", name)
if oldNode.Call("getAttribute", name).String() != val.String() {
oldNode.Call("setAttribute", name, val)
}
}
}
Ensure UpdateDOM handles nodes without attributes (e.g. comments) without panicking.
{
body := js.Doc().Get("body")
root := CreateElement("div")
root.Set("id", "root")
body.Call("appendChild", root)
defer root.Call("remove")
SetInnerHTML(root, "<!--old-->")
UpdateDOM("root", "<!--new-->")
}
{}
{
doc := Doc()
el := doc.CreateElement("div")
el.SetText("hello")
if got := el.Text(); got != "hello" {
t.Fatalf("Text() = %q", got)
}
}
{
doc := Doc()
if node := doc.Head().Get("nodeName").String(); node != "HEAD" {
t.Fatalf("Head() node = %q", node)
}
}
ScheduleRender updates the DOM of the specified component after a delay.
{
sched.Lock()
defer sched.Unlock()
if t, ok := sched.timers[componentID]; ok {
t.Stop()
}
sched.timers[componentID] = time.AfterFunc(delay, func() {
UpdateDOM(componentID, html)
sched.Lock()
delete(sched.timers, componentID)
sched.Unlock()
})
}
{
got := StyleInline(map[string]string{"color": "red", "display": "block"})
if !strings.Contains(got, "color:red") || !strings.Contains(got, "display:block") {
t.Fatalf("StyleInline() = %q", got)
}
}
Element wraps a DOM element and provides typed helpers.
On attaches a listener for event to the element and returns a stop function.
{
fn := js.FuncOf(func(this js.Value, args []js.Value) any {
var evt js.Value
if len(args) > 0 {
evt = args[0]
}
handler(Event{evt})
return nil
})
e.Call("addEventListener", event, fn)
return func() {
e.Call("removeEventListener", event, fn)
fn.Release()
}
}
OnClick attaches a click handler to the element.
{
return e.On("click", handler)
}
Query returns the first descendant matching the CSS selector.
{
return Element{e.Call("querySelector", sel)}
}
QueryAll returns all descendants matching the selector.
{
return Element{e.Call("querySelectorAll", sel)}
}
ByClass returns all descendants with the given class name.
{
return Element{e.Call("getElementsByClassName", name)}
}
ByTag returns all descendants with the given tag name.
{
return Element{e.Call("getElementsByTagName", tag)}
}
Text returns the element's text content.
{ return e.Get("textContent").String() }
SetText sets the element's text content.
{ e.Set("textContent", txt) }
HTML returns the element's inner HTML.
{ return e.Get("innerHTML").String() }
SetHTML replaces the element's children with raw HTML.
{ e.Set("innerHTML", html) }
AppendChild appends a child element.
{ e.Call("appendChild", child.Value) }
Attr retrieves the value of an attribute or "" if unset.
{
v := e.Call("getAttribute", name)
if v.Truthy() {
return v.String()
}
return ""
}
SetAttr sets the value of an attribute on the element.
{ e.Call("setAttribute", name, value) }
SetStyle sets an inline style property on the element.
{
e.Get("style").Call("setProperty", prop, value)
}
AddClass adds a class to the element.
{ e.Get("classList").Call("add", name) }
RemoveClass removes a class from the element.
{ e.Get("classList").Call("remove", name) }
HasClass reports whether the element has the given class.
{
return e.Get("classList").Call("contains", name).Bool()
}
ToggleClass toggles the presence of a class on the element.
{
e.Get("classList").Call("toggle", name)
}
Length returns the number of children when the element represents a collection.
{ return e.Get("length").Int() }
Index retrieves the element at the given position when representing a collection.
{ return Element{e.Value.Index(i)} }
{
doc := Doc()
el := doc.CreateElement("div")
el.SetAttr("data-x", "y")
if got := el.Attr("data-x"); got != "y" {
t.Fatalf("Attr() = %q", got)
}
el.SetHTML("<span>ok</span>")
if got := el.HTML(); got != "<span>ok</span>" {
t.Fatalf("HTML() = %q", got)
}
el.SetStyle("color", "red")
if v := el.Get("style").Call("getPropertyValue", "color").String(); v != "red" {
t.Fatalf("style color = %q", v)
}
}
{
doc := Doc()
parent := doc.CreateElement("div")
parent.SetHTML("<span>a</span><span>b</span>")
spans := parent.QueryAll("span")
if spans.Length() != 2 {
t.Fatalf("Length() = %d", spans.Length())
}
second := spans.Index(1)
if second.Text() != "b" {
t.Fatalf("Index(1).Text() = %q", second.Text())
}
second.ToggleClass("x")
if !second.HasClass("x") {
t.Fatalf("ToggleClass/HasClass failed")
}
}
{
doc := Doc()
parent := doc.CreateElement("div")
child := doc.CreateElement("span")
parent.AppendChild(child)
if got := parent.Query("span"); !got.Truthy() {
t.Fatalf("AppendChild() did not append")
}
}
RegisterHandler registers a Go function with custom arguments in the handler registry.
{
handlerRegistry[name] = js.FuncOf(fn).Value
}
RegisterHandlerFunc registers a no-argument Go function in the handler registry.
{
RegisterHandler(name, func(this js.Value, args []js.Value) any {
fn()
return nil
})
}
RegisterHandlerEvent registers a Go function that receives the first argument as an event object.
{
RegisterHandler(name, func(this js.Value, args []js.Value) any {
var evt js.Value
if len(args) > 0 {
evt = args[0]
}
fn(evt)
return nil
})
}
GetHandler retrieves a registered handler by name.
{
if v, ok := handlerRegistry[name]; ok {
return v
}
return js.Undefined()
}
import "strings"
import "github.com/rfwlab/rfw/v1/js"
js
import "fmt"
import "strings"
import "golang.org/x/net/html"
import "github.com/rfwlab/rfw/v1/js"
js
import "fmt"
import "regexp"
import "strings"
import "golang.org/x/net/html"
import "github.com/rfwlab/rfw/v1/js"
js
import "fmt"
import "regexp"
import "strings"
import "sync"
import "github.com/rfwlab/rfw/v1/events"
events
import "github.com/rfwlab/rfw/v1/js"
js
import "github.com/rfwlab/rfw/v1/state"
import "testing"
import "github.com/rfwlab/rfw/v1/js"
js
import "testing"
import "testing"
import "sync"
import "time"
import "strings"
import "testing"
import "github.com/rfwlab/rfw/v1/js"
js
import "testing"
import "github.com/rfwlab/rfw/v1/js"
js