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:
15
ui/app.go
Normal file
15
ui/app.go
Normal 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
29
ui/app_router.go
Normal 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
9
ui/button.go
Normal 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
1124
ui/canvas.go
Normal file
File diff suppressed because it is too large
Load Diff
51
ui/canvas_config.go
Normal file
51
ui/canvas_config.go
Normal 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
36
ui/canvas_errors.go
Normal 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
144
ui/canvas_geometry.go
Normal 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
42
ui/font.go
Normal 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
7
ui/image.go
Normal 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
208
ui/input.go
Normal 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
90
ui/layout.go
Normal 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
172
ui/modifiers.go
Normal 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
264
ui/svg.go
Normal 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
23
ui/text.go
Normal 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
10
ui/utils.go
Normal 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
29
ui/view.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user