Skip to content

Latest commit

 

History

249 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

webtyp/dom

Ultra-minimal DOM & reactivity toolkit for Go (TinyGo WASM-optimized).

webtyp/dom provides a type-safe, fine-grained reactive engine over the browser DOM for TinyGo/WASM. State lives in typed Signals; changing a signal patches only the bound DOM node — no Virtual DOM, no manual Update() calls, no re-renders.

Features

  • Fine-Grained Reactivity: SignalString / SignalBool / SignalNodes — O(1) surgical patches that preserve focus and IME composition.
  • Auto-tracking: BindTextFunc / DeriveString discover dependencies automatically — no explicit dep lists.
  • Typed builder: Text, Child, Attr, Class, Set(kv ...fmt.KeyValue) — no Add(...any).
  • Two-method contract: Render() *Element (pure, once per mount) + optional Init(ctx dom.Ctx) (side effects, once ever).
  • Keyed lists & conditional subtrees: BindChildren(SignalNodes) + Show(cond, content).
  • No Virtual DOM: Zero diffing; nodes are never replaced unless structure truly changes.
  • TinyGo Optimized: Zero stdlib; webtyp/fmt for logs; slices over maps; <500KB WASM binaries.
  • Isomorphic: same Render() produces correct SSR HTML on backend and live WASM on frontend.

Installation

go get webtyp.com/dom

Quick Start

import (
    dom "webtyp.com/dom"
    "webtyp.com/fmt"
    "webtyp.com/html"
)

type Counter struct {
    dom.Element
    n     int
    count *dom.SignalString
}

func (c *Counter) Init(ctx dom.Ctx) {
    c.count = dom.NewString("0")
}

func (c *Counter) Render() *dom.Element {
    return html.Div().Child(
        html.Span().BindText(c.count).Class("count"),
        html.Button().Text("Increment").OnClick(func(e dom.Event) {
            c.n++
            c.count.Set(fmt.Sprint(c.n))
        }),
    )
}

func main() {
    d := dom.New(...)
    d.Render("app", &Counter{})
}

Component Contract

Method Role Cardinality
Render() *Element Pure: state → structure, no side effects Once per mount
Init(ctx dom.Ctx) Imperative: create signals, load storage, start timers Exactly once (before render)
Mounted() Imperative: DOM operations (measure, focus, scroll) On every insertion

Init and Mounted are optional — only add them when there is work to do.

Reaching Live Elements with Key + Ref

To reach a live DOM node that a component built itself, store the *Element, assign it a .Key(...), and call .Ref() after render:

type RowComp struct {
    dom.Element
    row *dom.Element
}

func (c *RowComp) Render() *dom.Element {
    c.row = html.Span().Key("row").Text("initial")
    return html.Div().Child(c.row)
}

func (c *RowComp) Mounted() {
    if row, ok := c.row.Ref(); ok {
        row.SetText("updated")
    }
}

Ref() answers false until the element has been rendered, so call it from Mounted() or from an event handler — never from Render() itself.

Signals

// String cell — UI text, attr, input state
name := dom.NewString("World")
name.Get()           // "World"
name.Set("Alice")    // notifies all bindings
name.Update(func(v string) string { return v + "!" })

// Bool cell — class/attr toggles, Show conditions
active := dom.NewBool(false)
active.Toggle()

// List of rendered rows — keyed reconcile
rows := dom.NewNodes(elem1, elem2)
rows.Set(newRows)

// Derived (auto-tracking — no deps list)
full := dom.DeriveString(func() string { return first.Get() + " " + last.Get() })

Element Builder

html.Div().
    Class("card").
    Attr("role", "region").
    Text(userInput).                      // Escaped automatically (< → &lt;)
    Raw(dom.Trust("<b>trusted HTML</b>")). // Explicit raw markup (requires dom.Trust)
    Child(
        html.Span().BindText(name),
        html.Input("text").Bind(name),           // two-way
        html.Button().Text("Save").BindAttrBool("disabled", saving),
    )

Builders take no arguments — children go in Child(...) (variadic) and text in .Text(...). The only exceptions are A(href), Input(type), Option(value, text) and SelectedOption(value, text).

Binding methods:

Method DOM target
.BindText(s *SignalString) textContent
.BindAttr(name, s) attribute value
.BindClass(class, on) class toggle
.BindAttrBool(name, on) boolean attribute (disabled, checked…)
.Bind(s) two-way <input>/<textarea>
.BindChildren(s *SignalNodes) keyed child list
.BindTextFunc(fn) computed text (auto-tracking)
.Autofocus() focus on first appearance

Structural:

dom.Show(visible, html.Div().Child(...))  // toggle subtree visibility via display:none
html.Ul().BindChildren(c.rows)                                          // keyed list

Events

I want Use
click .OnClick(func(e dom.Event))
change .OnChange(func(e dom.Event))
input .OnInput(func(e dom.Event))
blur .OnBlur(func(e dom.Event))
submit .OnSubmit(func(e dom.Event))
toggle .OnToggle(func(e dom.Event))
mouseenter .OnMouseEnter(func(e dom.Event))
mouseleave .OnMouseLeave(func(e dom.Event))
focusin .OnFocusIn(func(e dom.Event))
focusout .OnFocusOut(func(e dom.Event))
keydown .OnKeyDown(func(e dom.KeyEvent)) + e.Key() == dom.KeyArrowLeft

There is no .On(string, …): a typo'd type string compiled, never fired, and reported nothing. Keyboard input reads through dom.Key (KeyArrowLeft/Right/Up/Down, KeyHome/End, KeyPageUp/PageDown, KeyEnter, KeySpace, KeyEscape, KeyTab).

Page-level listeners

  • dom.OnHashChange(func(hash string)): URL hash changes (#hash).
  • dom.OnScrollCapture(func(scrollTop float64)): document capture scroll.
  • dom.OnUserActivity(func()): document capture presence pulse (throttled to ~1/s).
// In Init(ctx) — once. dom reports the pulse; the timeout is yours.
dom.OnUserActivity(func() { shell.lastSeen = time.Now() })

Lifecycle

Init (once) → Render → Insert → Wire bindings & events → children Mounted → own Mounted
signal.Set  → patch bound node (O(1))
unmount     → run OnCleanup + unsubscribe signals

Mount Point

Always "app", never "body"Render("body", ...) overwrites innerHTML and destroys the SVG sprite injected by webtyp/sitec.

Dev Mode

dom.SetDevMode(true) // enabled at runtime; default false (production no-op)

When on:

  • Reactive trace: logs signal.Set → patch #node-id
  • BindChildren warns on duplicate/empty keys
  • Nil signal / non-input .Bind / pointer-embedded Element emit warnings instead of panicking

Testing

Use webtyp.com/dom/domtest for testing components against a live DOM in WASM (//go:build wasm test files). It is test-only and contributes nothing to application WASM binaries.

Five functions: Mount, Query, Text, Fire, Fill.

func TestCounter(t *testing.T) {
    domtest.Mount(t, "app")
    comp := &Counter{}
    if err := dom.Render("app", comp); err != nil {
        t.Fatalf("Render: %v", err)
    }
    domtest.Fire(".btn", "click")
    if got := domtest.Text(".count"); got != "1" {
        t.Errorf("want '1', got %q", got)
    }
}

Related Packages

Documentation

License

MIT

About

Ultra-minimal DOM & event toolkit for Go (TinyGo WASM-optimized).

Topics

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Sponsor this project

Packages

Contributors

Languages