Initial iris repository structure
Some checks failed
CI / build (push) Failing after 36s

WASM reactive UI framework for Go:
- reactive/ - Signal[T], Effect, Runtime
- ui/ - Button, Text, Input, View, Canvas, SVG components
- navigation/ - Router, guards, history management
- auth/ - OIDC client for WASM applications
- host/ - Static file server

Extracted from arcadia as open-source component.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-01-08 19:23:49 +01:00
commit 00d98879d3
36 changed files with 4181 additions and 0 deletions

15
ui/app.go Normal file
View File

@@ -0,0 +1,15 @@
package ui
import (
_ "syscall/js"
"git.flowmade.one/flowmade-one/iris/internal/element"
)
func NewApp(view View) {
app := NewView()
app.e.Grid()
app.MinHeight("100vh")
app.Child(view)
element.Mount(app.e)
}

29
ui/app_router.go Normal file
View File

@@ -0,0 +1,29 @@
package ui
import (
"git.flowmade.one/flowmade-one/iris/internal/element"
"git.flowmade.one/flowmade-one/iris/reactive"
)
// Router interface to avoid import cycle
type Router interface {
GetCurrentView() View
}
// NewAppWithRouter creates a new app with navigation support
func NewAppWithRouter(router Router) {
app := NewView()
app.e.Grid()
app.MinHeight("100vh")
// Create a reactive view that updates when routes change
reactive.NewEffect(func() {
currentView := router.GetCurrentView()
// Clear the app and add the current view
// Note: In a production implementation, we'd want better DOM diffing
app.e.Set("innerHTML", "")
app.Child(currentView)
})
element.Mount(app.e)
}

9
ui/button.go Normal file
View File

@@ -0,0 +1,9 @@
package ui
import "git.flowmade.one/flowmade-one/iris/internal/element"
func Button(action func(), label View) View {
btn := View{element.NewElement("button").On("click", action)}
btn.Child(label)
return btn
}

1124
ui/canvas.go Normal file

File diff suppressed because it is too large Load Diff

51
ui/canvas_config.go Normal file
View File

@@ -0,0 +1,51 @@
//go:build js && wasm
package ui
// CanvasDefaults contains default values for canvas rendering and interaction
type CanvasDefaults struct {
// Interaction thresholds
EdgeThreshold float64 // Distance from edge to trigger connection drawing (default: 15)
DragThreshold float64 // Minimum movement to count as drag vs click (default: 2)
// Resize constraints
MinResizeWidth float64 // Minimum width when resizing (default: 100)
MinResizeHeight float64 // Minimum height when resizing (default: 100)
// Resize handle sizes
CornerHandleSize float64 // Size of corner resize handles (default: 12)
EdgeHandleWidth float64 // Width of edge resize handles (default: 8)
EdgeHandleLength float64 // Length of edge resize handles (default: 24)
// Connection rendering
ConnectionHitWidth float64 // Width of invisible hit area for connections (default: 12)
LabelBackgroundWidth float64 // Width of label background rect (default: 60)
LabelBackgroundHeight float64 // Height of label background rect (default: 20)
// Container layout
ContainerHeaderHeight float64 // Height of container header bar for click detection (default: 32)
}
// DefaultCanvasDefaults returns the default configuration values
var DefaultCanvasDefaults = CanvasDefaults{
// Interaction thresholds
EdgeThreshold: 15.0,
DragThreshold: 2.0,
// Resize constraints
MinResizeWidth: 100.0,
MinResizeHeight: 100.0,
// Resize handle sizes
CornerHandleSize: 12.0,
EdgeHandleWidth: 8.0,
EdgeHandleLength: 24.0,
// Connection rendering
ConnectionHitWidth: 12.0,
LabelBackgroundWidth: 60.0,
LabelBackgroundHeight: 20.0,
// Container layout
ContainerHeaderHeight: 32.0,
}

36
ui/canvas_errors.go Normal file
View File

@@ -0,0 +1,36 @@
//go:build js && wasm
package ui
import "log"
// CanvasWarning represents a type of canvas warning
type CanvasWarning int
const (
WarnItemNotFound CanvasWarning = iota
WarnConnectionNotFound
WarnInvalidOperation
WarnSelfConnection
WarnMissingCallback
)
// warningNames maps warning types to human-readable names
var warningNames = map[CanvasWarning]string{
WarnItemNotFound: "ItemNotFound",
WarnConnectionNotFound: "ConnectionNotFound",
WarnInvalidOperation: "InvalidOperation",
WarnSelfConnection: "SelfConnection",
WarnMissingCallback: "MissingCallback",
}
// LogCanvasWarning logs a warning for recoverable canvas issues.
// These are situations where the operation cannot complete but the
// application can continue safely.
func LogCanvasWarning(warn CanvasWarning, format string, args ...any) {
name := warningNames[warn]
if name == "" {
name = "Unknown"
}
log.Printf("[Canvas %s] "+format, append([]any{name}, args...)...)
}

144
ui/canvas_geometry.go Normal file
View File

@@ -0,0 +1,144 @@
//go:build js && wasm
package ui
import (
"sort"
"git.flowmade.one/flowmade-one/iris/internal/element"
)
// snapToGrid snaps a position to the grid if enabled
func snapToGrid(pos Point, gridSize int) Point {
if gridSize <= 0 {
return pos
}
return Point{
X: float64(int(pos.X/float64(gridSize)+0.5) * gridSize),
Y: float64(int(pos.Y/float64(gridSize)+0.5) * gridSize),
}
}
// findItemUnderPoint finds which canvas item is under the given screen coordinates
// Returns the item with highest ZIndex that contains the point
func findItemUnderPoint(state *CanvasState, viewport element.Element, clientX, clientY float64) string {
// Get viewport bounds
rect := viewport.Call("getBoundingClientRect")
viewportLeft := rect.Get("left").Float()
viewportTop := rect.Get("top").Float()
// Convert to canvas coordinates
x := clientX - viewportLeft
y := clientY - viewportTop
canvasPos := state.ScreenToCanvas(Point{X: x, Y: y})
// Sort items by ZIndex descending (higher values first = top items first)
items := state.Items.Get()
sortedItems := make([]CanvasItem, len(items))
copy(sortedItems, items)
sort.Slice(sortedItems, func(i, j int) bool {
return sortedItems[i].ZIndex > sortedItems[j].ZIndex
})
// Check items in z-order (top to bottom)
for _, item := range sortedItems {
if canvasPos.X >= item.Position.X && canvasPos.X <= item.Position.X+item.Size.X &&
canvasPos.Y >= item.Position.Y && canvasPos.Y <= item.Position.Y+item.Size.Y {
return item.ID
}
}
return ""
}
// isNearEdge checks if a click position is near the edge of an element
func isNearEdge(clickX, clickY, elemWidth, elemHeight float64) bool {
edgeThreshold := DefaultCanvasDefaults.EdgeThreshold
return clickX < edgeThreshold ||
clickX > elemWidth-edgeThreshold ||
clickY < edgeThreshold ||
clickY > elemHeight-edgeThreshold
}
// calculateEdgePoint calculates the edge point for a connection based on click position
func calculateEdgePoint(item CanvasItem, clickX, clickY, elemWidth, elemHeight float64) Point {
// Calculate center of element
centerX := item.Position.X + item.Size.X/2
centerY := item.Position.Y + item.Size.Y/2
// Determine which edge is closest
distLeft := clickX
distRight := elemWidth - clickX
distTop := clickY
distBottom := elemHeight - clickY
minDist := distLeft
edgeX := item.Position.X
edgeY := centerY
if distRight < minDist {
minDist = distRight
edgeX = item.Position.X + item.Size.X
edgeY = centerY
}
if distTop < minDist {
minDist = distTop
edgeX = centerX
edgeY = item.Position.Y
}
if distBottom < minDist {
edgeX = centerX
edgeY = item.Position.Y + item.Size.Y
}
return Point{X: edgeX, Y: edgeY}
}
// calculateConnectionEdgePoint calculates where a line from center to target
// should intersect with the element's bounding box
func calculateConnectionEdgePoint(center, target, size Point) Point {
dx := target.X - center.X
dy := target.Y - center.Y
// Handle edge case of overlapping centers
if dx == 0 && dy == 0 {
return center
}
// Calculate intersection with bounding box
halfW := size.X / 2
halfH := size.Y / 2
// Check which edge we intersect
if dx == 0 {
// Vertical line
if dy > 0 {
return Point{X: center.X, Y: center.Y + halfH}
}
return Point{X: center.X, Y: center.Y - halfH}
}
slope := dy / dx
// Check horizontal edges
if abs(slope) <= halfH/halfW {
// Intersects left or right edge
if dx > 0 {
return Point{X: center.X + halfW, Y: center.Y + slope*halfW}
}
return Point{X: center.X - halfW, Y: center.Y - slope*halfW}
}
// Intersects top or bottom edge
if dy > 0 {
return Point{X: center.X + halfH/slope, Y: center.Y + halfH}
}
return Point{X: center.X - halfH/slope, Y: center.Y - halfH}
}
// abs returns the absolute value of x
func abs(x float64) float64 {
if x < 0 {
return -x
}
return x
}

42
ui/font.go Normal file
View File

@@ -0,0 +1,42 @@
package ui
type Font struct {
family string
size string
weight string
lineheight string
}
func NewFont() Font {
return Font{
family: "sans-serif",
size: "16px",
weight: "400",
lineheight: "20px",
}
}
func (f Font) Family(family string) Font {
f.family = family
return f
}
func (f Font) Size(size string) Font {
f.size = size
return f
}
func (f Font) Weight(weight string) Font {
f.weight = weight
return f
}
func (f Font) String() string {
return f.weight + " " + f.size + " " + f.family + ""
}
// todo we can't apply this straight via the css, we need to apply it to the lineheight of the parent
func (f Font) LineHeight(lineheight string) Font {
f.lineheight = lineheight
return f
}

7
ui/image.go Normal file
View File

@@ -0,0 +1,7 @@
package ui
import "git.flowmade.one/flowmade-one/iris/internal/element"
func Image(src string) View {
return View{element.NewElement("img").Attr("src", src)}
}

208
ui/input.go Normal file
View File

@@ -0,0 +1,208 @@
package ui
import (
"strconv"
"git.flowmade.one/flowmade-one/iris/internal/element"
"git.flowmade.one/flowmade-one/iris/reactive"
)
// TextInput creates a reactive text input field
func TextInput(value *reactive.Signal[string], placeholder string) View {
input := element.NewElement("input")
input.Attr("type", "text")
input.Attr("placeholder", placeholder)
// Set initial value
input.Set("value", value.Get())
// Update input when signal changes
reactive.NewEffect(func() {
currentValue := value.Get()
if input.Get("value").String() != currentValue {
input.Set("value", currentValue)
}
})
// Update signal when user types
input.On("input", func() {
newValue := input.Get("value").String()
if value.Get() != newValue {
value.Set(newValue)
}
})
return View{input}
}
// TextArea creates a multi-line text input
func TextArea(value *reactive.Signal[string], placeholder string, rows int) View {
textarea := element.NewElement("textarea")
textarea.Attr("placeholder", placeholder)
textarea.Attr("rows", string(rune(rows)))
// Set initial value
textarea.Set("value", value.Get())
// Update textarea when signal changes
reactive.NewEffect(func() {
currentValue := value.Get()
if textarea.Get("value").String() != currentValue {
textarea.Set("value", currentValue)
}
})
// Update signal when user types
textarea.On("input", func() {
newValue := textarea.Get("value").String()
if value.Get() != newValue {
value.Set(newValue)
}
})
return View{textarea}
}
// Checkbox creates a reactive checkbox input
func Checkbox(checked *reactive.Signal[bool], label string) View {
container := element.NewElement("label")
container.Get("style").Call("setProperty", "display", "flex")
container.Get("style").Call("setProperty", "align-items", "center")
container.Get("style").Call("setProperty", "gap", "8px")
container.Get("style").Call("setProperty", "cursor", "pointer")
checkbox := element.NewElement("input")
checkbox.Attr("type", "checkbox")
// Set initial state
if checked.Get() {
checkbox.Set("checked", true)
}
// Update checkbox when signal changes
reactive.NewEffect(func() {
isChecked := checked.Get()
checkbox.Set("checked", isChecked)
})
// Update signal when user clicks
checkbox.On("change", func() {
newValue := checkbox.Get("checked").Bool()
checked.Set(newValue)
})
// Add label text
labelText := element.NewTextNode(label)
container.Child(checkbox)
container.Child(labelText)
return View{container}
}
// Select creates a dropdown selection component
func Select(selected *reactive.Signal[string], options []string, placeholder string) View {
selectElem := element.NewElement("select")
// Add placeholder option if provided
if placeholder != "" {
placeholderOption := element.NewElement("option")
placeholderOption.Set("value", "")
placeholderOption.Set("disabled", true)
placeholderOption.Set("selected", true)
placeholderOption.Set("textContent", placeholder)
selectElem.Child(placeholderOption)
}
// Add options
for _, option := range options {
optionElem := element.NewElement("option")
optionElem.Set("value", option)
optionElem.Set("textContent", option)
selectElem.Child(optionElem)
}
// Set initial value
selectElem.Set("value", selected.Get())
// Update select when signal changes
reactive.NewEffect(func() {
currentValue := selected.Get()
selectElem.Set("value", currentValue)
})
// Update signal when user selects
selectElem.On("change", func() {
newValue := selectElem.Get("value").String()
selected.Set(newValue)
})
return View{selectElem}
}
// Slider creates a range input for numeric values
func Slider(value *reactive.Signal[int], min, max int) View {
slider := element.NewElement("input")
slider.Attr("type", "range")
slider.Attr("min", strconv.Itoa(min))
slider.Attr("max", strconv.Itoa(max))
// Set initial value
slider.Set("value", strconv.Itoa(value.Get()))
// Update slider when signal changes
reactive.NewEffect(func() {
currentValue := value.Get()
slider.Set("value", strconv.Itoa(currentValue))
})
// Update signal when user drags
slider.On("input", func() {
newValueStr := slider.Get("value").String()
if newValue, err := strconv.Atoi(newValueStr); err == nil {
value.Set(newValue)
}
})
return View{slider}
}
// NumberInput creates a reactive numeric input field for float64 values
func NumberInput(value *reactive.Signal[float64], placeholder string) View {
input := element.NewElement("input")
input.Attr("type", "number")
input.Attr("step", "0.01")
input.Attr("min", "0")
input.Attr("placeholder", placeholder)
// Set initial value
if value.Get() != 0 {
input.Set("value", strconv.FormatFloat(value.Get(), 'f', -1, 64))
}
// Update input when signal changes
reactive.NewEffect(func() {
currentValue := value.Get()
var displayValue string
if currentValue == 0 {
displayValue = ""
} else {
displayValue = strconv.FormatFloat(currentValue, 'f', -1, 64)
}
if input.Get("value").String() != displayValue {
input.Set("value", displayValue)
}
})
// Update signal when user types
input.On("input", func() {
newValueStr := input.Get("value").String()
if newValueStr == "" {
value.Set(0.0)
} else if newValue, err := strconv.ParseFloat(newValueStr, 64); err == nil {
value.Set(newValue)
}
})
return View{input}
}

90
ui/layout.go Normal file
View File

@@ -0,0 +1,90 @@
package ui
import "strings"
// VerticalGroup is a view that arranges its children vertically
// It uses CSS Grid with a single column
func VerticalGroup(children ...View) View {
v := NewView()
v.e.Grid()
v.e.GridTemplateColumns("1fr") // Force single column
v.e.GridAutoFlow("row")
v.e.Width("100%")
for _, child := range children {
v.Child(child)
}
return v
}
// HorizontalGroup is a view that arranges its children horizontally
// It uses flexbox to do so
func HorizontalGroup(children ...View) View {
v := NewView()
v.e.Grid()
v.e.GridTemplateColumns("repeat(auto-fit, minmax(250px, 1fr))")
v.e.Width("100%")
for _, child := range children {
v.Child(child)
}
return v
}
// Spacer is a view that takes up all available space
// It uses flexbox to do so
func Spacer() View {
v := NewView()
v.e.Get("style").Call("setProperty", "flex", "1")
return v
}
// OverlayGroup creates a container where children are stacked on top of each other
func OverlayGroup(children ...View) View {
v := NewView()
v.e.Get("style").Call("setProperty", "position", "relative")
v.e.Get("style").Call("setProperty", "display", "block")
// Add children with absolute positioning (except first one)
for i, child := range children {
if i == 0 {
// First child acts as the base layer
v.Child(child)
} else {
// Subsequent children are absolutely positioned as overlays
// They need pointer-events: none so clicks pass through to layers below
child.e.Get("style").Call("setProperty", "position", "absolute")
child.e.Get("style").Call("setProperty", "top", "0")
child.e.Get("style").Call("setProperty", "left", "0")
child.e.Get("style").Call("setProperty", "width", "100%")
child.e.Get("style").Call("setProperty", "height", "100%")
child.e.Get("style").Call("setProperty", "pointer-events", "none")
v.Child(child)
}
}
return v
}
// Divider creates a visual separator line
func Divider() View {
v := NewView()
v.e.Get("style").Call("setProperty", "border-top", "1px solid #ccc")
v.e.Get("style").Call("setProperty", "margin", "8px 0")
v.e.Get("style").Call("setProperty", "width", "100%")
v.e.Get("style").Call("setProperty", "height", "1px")
return v
}
// FlexLayout creates a horizontal layout with explicit column sizing
// Example: FlexLayout([]string{"48px", "1fr", "240px"}, left, center, right)
// Supports fixed widths (e.g., "48px"), flexible ("1fr"), and auto-sized ("auto")
func FlexLayout(columns []string, children ...View) View {
v := NewView()
v.e.Grid()
v.e.Get("style").Call("setProperty", "grid-template-columns", strings.Join(columns, " "))
v.e.Width("100%")
v.e.Height("100%")
for _, child := range children {
v.Child(child)
}
return v
}

172
ui/modifiers.go Normal file
View File

@@ -0,0 +1,172 @@
package ui
func (v View) SpaceEvenly() View {
v.e.Get("style").Call("setProperty", "justify-content", "space-evenly")
return v
}
func (v View) SpaceBetween() View {
v.e.Get("style").Call("setProperty", "justify-content", "space-between")
return v
}
func (v View) AlignCenter() View {
v.e.Get("style").Call("setProperty", "align-items", "center")
return v
}
func (v View) AlignRight() View {
v.e.Get("style").Call("setProperty", "align-items", "flex-end")
return v
}
func (v View) AlignLeft() View {
v.e.Get("style").Call("setProperty", "align-items", "flex-start")
return v
}
func (v View) JustifyContent(justify string) View {
v.e.Get("style").Call("setProperty", "justify-content", justify)
return v
}
func (v View) Margin(margin string) View {
v.e.Get("style").Call("setProperty", "margin", margin)
return v
}
func (v View) Padding(padding string) View {
v.e.Get("style").Call("setProperty", "padding", padding)
return v
}
func (v View) Font(font Font) View {
v.e.Get("style").Call("setProperty", "font", font.String())
v.e.Get("style").Call("setProperty", "line-height", font.lineheight)
return v
}
func (v View) Color(color string) View {
v.e.Get("style").Call("setProperty", "color", color)
return v
}
func (v View) Gap(gap string) View {
v.e.Get("style").Call("setProperty", "gap", gap)
return v
}
func (v View) Border(border string) View {
v.e.Get("style").Call("setProperty", "border", border)
return v
}
func (v View) BorderTop(border string) View {
v.e.Get("style").Call("setProperty", "border-top", border)
return v
}
func (v View) BorderBottom(border string) View {
v.e.Get("style").Call("setProperty", "border-bottom", border)
return v
}
func (v View) BorderLeft(border string) View {
v.e.Get("style").Call("setProperty", "border-left", border)
return v
}
func (v View) BorderRight(border string) View {
v.e.Get("style").Call("setProperty", "border-right", border)
return v
}
func (v View) TextAlign(align string) View {
v.e.Get("style").Call("setProperty", "text-align", align)
return v
}
func (v View) MaxWidth(width string) View {
v.e.MaxWidth(width)
return v
}
func (v View) Width(width string) View {
v.e.Width(width)
return v
}
func (v View) Height(height string) View {
v.e.Height(height)
return v
}
func (v View) Background(color string) View {
v.e.BackgroundColor(color)
return v
}
func (v View) Foreground(color string) View {
v.e.Color(color)
return v
}
func (v View) MinHeight(height string) View {
v.e.MinHeight(height)
return v
}
func (v View) BoxShadow(shadow string) View {
v.e.BoxShadow(shadow)
return v
}
func (v View) BorderRadius(radius string) View {
v.e.BorderRadius(radius)
return v
}
func (v View) Cursor(cursor string) View {
v.e.Cursor(cursor)
return v
}
func (v View) Position(pos string) View {
v.e.Position(pos)
return v
}
func (v View) Left(value string) View {
v.e.Left(value)
return v
}
func (v View) Top(value string) View {
v.e.Top(value)
return v
}
func (v View) Overflow(value string) View {
v.e.Overflow(value)
return v
}
func (v View) Display(value string) View {
v.e.Get("style").Call("setProperty", "display", value)
return v
}
func (v View) GridTemplateColumns(value string) View {
v.e.Get("style").Call("setProperty", "grid-template-columns", value)
return v
}
func (v View) AlignItems(value string) View {
v.e.Get("style").Call("setProperty", "align-items", value)
return v
}
func (v View) PointerEvents(value string) View {
v.e.PointerEvents(value)
return v
}

264
ui/svg.go Normal file
View File

@@ -0,0 +1,264 @@
//go:build js && wasm
package ui
import (
"fmt"
"syscall/js"
"git.flowmade.one/flowmade-one/iris/internal/element"
)
const svgNS = "http://www.w3.org/2000/svg"
// SVG creates an SVG container element
func SVG() SVGElement {
e := element.NewElementNS(svgNS, "svg")
return SVGElement{e}
}
// SVGElement wraps an SVG element
type SVGElement struct {
e element.Element
}
// Attr sets an attribute on the SVG element
func (s SVGElement) Attr(name, value string) SVGElement {
s.e.Attr(name, value)
return s
}
// Width sets the width attribute
func (s SVGElement) Width(value string) SVGElement {
s.e.Attr("width", value)
return s
}
// Height sets the height attribute
func (s SVGElement) Height(value string) SVGElement {
s.e.Attr("height", value)
return s
}
// Position sets CSS position
func (s SVGElement) Position(pos string) SVGElement {
s.e.Position(pos)
return s
}
// Left sets CSS left
func (s SVGElement) Left(value string) SVGElement {
s.e.Left(value)
return s
}
// Top sets CSS top
func (s SVGElement) Top(value string) SVGElement {
s.e.Top(value)
return s
}
// PointerEvents sets CSS pointer-events
func (s SVGElement) PointerEvents(value string) SVGElement {
s.e.PointerEvents(value)
return s
}
// Child adds a child SVG element
func (s SVGElement) ChildSVG(child SVGElement) SVGElement {
s.e.Child(child.e)
return s
}
// ClearChildren removes all children
func (s SVGElement) ClearChildren() SVGElement {
s.e.ClearChildren()
return s
}
// ToView converts the SVG element to a View (for embedding in layouts)
func (s SVGElement) ToView() View {
return View{s.e}
}
// SVGDefs creates a defs element
func SVGDefs() SVGElement {
return SVGElement{element.NewElementNS(svgNS, "defs")}
}
// SVGMarker creates a marker element for arrowheads
func SVGMarker(id string) SVGElement {
e := element.NewElementNS(svgNS, "marker")
e.Attr("id", id)
return SVGElement{e}
}
// MarkerWidth sets marker width
func (s SVGElement) MarkerWidth(value string) SVGElement {
s.e.Attr("markerWidth", value)
return s
}
// MarkerHeight sets marker height
func (s SVGElement) MarkerHeight(value string) SVGElement {
s.e.Attr("markerHeight", value)
return s
}
// RefX sets refX
func (s SVGElement) RefX(value string) SVGElement {
s.e.Attr("refX", value)
return s
}
// RefY sets refY
func (s SVGElement) RefY(value string) SVGElement {
s.e.Attr("refY", value)
return s
}
// Orient sets orient
func (s SVGElement) Orient(value string) SVGElement {
s.e.Attr("orient", value)
return s
}
// MarkerUnits sets markerUnits
func (s SVGElement) MarkerUnits(value string) SVGElement {
s.e.Attr("markerUnits", value)
return s
}
// SVGPath creates a path element
func SVGPath(d string) SVGElement {
e := element.NewElementNS(svgNS, "path")
e.Attr("d", d)
return SVGElement{e}
}
// Fill sets fill color
func (s SVGElement) Fill(color string) SVGElement {
s.e.Attr("fill", color)
return s
}
// Stroke sets stroke color
func (s SVGElement) Stroke(color string) SVGElement {
s.e.Attr("stroke", color)
return s
}
// StrokeWidth sets stroke width
func (s SVGElement) StrokeWidth(value string) SVGElement {
s.e.Attr("stroke-width", value)
return s
}
// StrokeDasharray sets stroke dash pattern
func (s SVGElement) StrokeDasharray(value string) SVGElement {
s.e.Attr("stroke-dasharray", value)
return s
}
// MarkerEnd sets the end marker
func (s SVGElement) MarkerEnd(value string) SVGElement {
s.e.Attr("marker-end", value)
return s
}
// SVGLine creates a line element
func SVGLine(x1, y1, x2, y2 float64) SVGElement {
e := element.NewElementNS(svgNS, "line")
e.Attr("x1", fmt.Sprintf("%f", x1))
e.Attr("y1", fmt.Sprintf("%f", y1))
e.Attr("x2", fmt.Sprintf("%f", x2))
e.Attr("y2", fmt.Sprintf("%f", y2))
return SVGElement{e}
}
// SVGRect creates a rect element
func SVGRect(x, y, width, height float64) SVGElement {
e := element.NewElementNS(svgNS, "rect")
e.Attr("x", fmt.Sprintf("%f", x))
e.Attr("y", fmt.Sprintf("%f", y))
e.Attr("width", fmt.Sprintf("%f", width))
e.Attr("height", fmt.Sprintf("%f", height))
return SVGElement{e}
}
// SVGCircle creates a circle element
func SVGCircle(cx, cy, r float64) SVGElement {
e := element.NewElementNS(svgNS, "circle")
e.Attr("cx", fmt.Sprintf("%f", cx))
e.Attr("cy", fmt.Sprintf("%f", cy))
e.Attr("r", fmt.Sprintf("%f", r))
return SVGElement{e}
}
// SVGText creates a text element
func SVGText(text string, x, y float64) SVGElement {
e := element.NewElementNS(svgNS, "text")
e.Attr("x", fmt.Sprintf("%f", x))
e.Attr("y", fmt.Sprintf("%f", y))
e.Text(text)
return SVGElement{e}
}
// SVGGroup creates a g (group) element
func SVGGroup() SVGElement {
return SVGElement{element.NewElementNS(svgNS, "g")}
}
// OnClick adds a click handler to the SVG element
func (s SVGElement) OnClick(fn func()) SVGElement {
s.e.On("click", fn)
return s
}
// OnClickWithEvent adds a click handler with event access
func (s SVGElement) OnClickWithEvent(fn func(js.Value)) SVGElement {
s.e.OnWithEvent("click", fn)
return s
}
// DataAttr sets a data attribute (data-{name}="{value}")
func (s SVGElement) DataAttr(name, value string) SVGElement {
s.e.Attr("data-"+name, value)
return s
}
// ID sets the id attribute
func (s SVGElement) ID(id string) SVGElement {
s.e.Attr("id", id)
return s
}
// Cursor sets the CSS cursor property
func (s SVGElement) Cursor(value string) SVGElement {
s.e.Cursor(value)
return s
}
// FontSize sets the font-size attribute
func (s SVGElement) FontSize(value string) SVGElement {
s.e.Attr("font-size", value)
return s
}
// FontFamily sets the font-family attribute
func (s SVGElement) FontFamily(value string) SVGElement {
s.e.Attr("font-family", value)
return s
}
// TextAnchor sets the text-anchor attribute (start, middle, end)
func (s SVGElement) TextAnchor(value string) SVGElement {
s.e.Attr("text-anchor", value)
return s
}
// DominantBaseline sets the dominant-baseline attribute
func (s SVGElement) DominantBaseline(value string) SVGElement {
s.e.Attr("dominant-baseline", value)
return s
}

23
ui/text.go Normal file
View File

@@ -0,0 +1,23 @@
package ui
import (
"git.flowmade.one/flowmade-one/iris/internal/element"
"git.flowmade.one/flowmade-one/iris/reactive"
)
func TextFromString(text string) View {
v := View{element.NewElement("p")}
v.e.Set("textContent", text)
return v
}
func TextFromFunction(fn func() string) View {
textNode := element.NewElement("p")
reactive.NewEffect(func() {
value := fn()
textNode.Set("textContent", value)
})
return View{textNode}
}

10
ui/utils.go Normal file
View File

@@ -0,0 +1,10 @@
//go:build js && wasm
package ui
import "fmt"
// FormatPx formats a float as a CSS pixel value
func FormatPx(v float64) string {
return fmt.Sprintf("%.0fpx", v)
}

29
ui/view.go Normal file
View File

@@ -0,0 +1,29 @@
package ui
import "git.flowmade.one/flowmade-one/iris/internal/element"
type View struct {
e element.Element
}
func (v View) Child(child View) View {
v.e.Child(child.e)
return v
}
func NewView() View {
e := element.NewElement("div")
e.JustifyItems("center")
e.AlignItems("center")
return View{e}
}
// NewViewFromElement creates a View from an element (for advanced use cases)
func NewViewFromElement(e element.Element) View {
return View{e}
}
// Element returns the underlying element (for advanced use cases)
func (v View) Element() element.Element {
return v.e
}