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

90
navigation/components.go Normal file
View File

@@ -0,0 +1,90 @@
package navigation
import (
"git.flowmade.one/flowmade-one/iris/reactive"
"git.flowmade.one/flowmade-one/iris/ui"
)
var globalRouter *Router
var globalHistory *HistoryManager
func SetGlobalRouter(router *Router) {
globalRouter = router
globalHistory = NewHistoryManager(router)
globalHistory.Start()
}
func GetGlobalRouter() *Router {
return globalRouter
}
// RouterView renders the current route's view
func RouterView() ui.View {
if globalRouter == nil {
return ui.TextFromString("Router not initialized").Color("#ff4444")
}
// Create a view that updates when the route changes
view := ui.NewView()
reactive.NewEffect(func() {
currentView := globalRouter.GetCurrentView()
// Clear previous content and add new view
// Note: This is a simplified approach - in a real implementation
// we'd need better DOM diffing
view = currentView
})
return view
}
// Link creates a navigational link that updates the route
func Link(path string, content ui.View) ui.View {
if globalHistory == nil {
return content
}
// Create a simple clickable element that navigates with proper styling for horizontal layout
button := ui.Button(func() {
globalHistory.PushState(path)
}, content)
// Style the button to look like a link and work in horizontal layouts
return button.Padding("8px 12px").Background("transparent").Border("none")
}
// Navigate programmatically navigates to a path
func Navigate(path string) {
if globalHistory != nil {
globalHistory.PushState(path)
}
}
// Replace programmatically replaces current path
func Replace(path string) {
if globalHistory != nil {
globalHistory.ReplaceState(path)
}
}
// Back navigates back in history
func Back() {
if globalHistory != nil {
globalHistory.Back()
}
}
// Forward navigates forward in history
func Forward() {
if globalHistory != nil {
globalHistory.Forward()
}
}
// GetCurrentPath returns the current route path
func GetCurrentPath() string {
if globalRouter != nil {
return globalRouter.GetCurrentPath()
}
return ""
}

69
navigation/core.go Normal file
View File

@@ -0,0 +1,69 @@
package navigation
import (
"strings"
)
// Core routing logic separated from UI dependencies
type RoutePattern struct {
parts []string
params []string
}
func CompileRoutePattern(path string) RoutePattern {
var params []string
parts := strings.Split(strings.Trim(path, "/"), "/")
if len(parts) == 1 && parts[0] == "" {
parts = []string{} // Handle root path
}
// Find parameters like :id, :name, etc.
for _, part := range parts {
if strings.HasPrefix(part, ":") {
paramName := part[1:] // Remove the ':'
// Simple validation: param name should start with letter
if len(paramName) > 0 && isLetterCore(paramName[0]) {
params = append(params, paramName)
}
}
}
return RoutePattern{parts, params}
}
func MatchRoute(routePattern RoutePattern, path string) map[string]string {
pathParts := strings.Split(strings.Trim(path, "/"), "/")
if len(pathParts) == 1 && pathParts[0] == "" {
pathParts = []string{} // Handle root path
}
if len(pathParts) != len(routePattern.parts) {
return nil
}
params := make(map[string]string)
paramIndex := 0
for i, routePart := range routePattern.parts {
if strings.HasPrefix(routePart, ":") {
// This is a parameter
if paramIndex < len(routePattern.params) {
params[routePattern.params[paramIndex]] = pathParts[i]
paramIndex++
}
} else {
// Static part must match exactly
if routePart != pathParts[i] {
return nil
}
}
}
return params
}
// isLetterCore checks if a byte is a letter
func isLetterCore(b byte) bool {
return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z')
}

91
navigation/guards.go Normal file
View File

@@ -0,0 +1,91 @@
package navigation
import "strings"
// Common route guards
// AuthGuard checks if user is authenticated
func AuthGuard(isAuthenticated func() bool) RouteGuard {
return func(route *Route, params map[string]string) bool {
return isAuthenticated()
}
}
// RoleGuard checks if user has required role
func RoleGuard(userRole func() string, requiredRole string) RouteGuard {
return func(route *Route, params map[string]string) bool {
return userRole() == requiredRole
}
}
// PermissionGuard checks if user has specific permission
func PermissionGuard(hasPermission func(permission string) bool, permission string) RouteGuard {
return func(route *Route, params map[string]string) bool {
return hasPermission(permission)
}
}
// ParamValidationGuard validates route parameters
func ParamValidationGuard(validator func(params map[string]string) bool) RouteGuard {
return func(route *Route, params map[string]string) bool {
return validator(params)
}
}
// PathPrefixGuard checks if the current path starts with a specific prefix
func PathPrefixGuard(prefix string) RouteGuard {
return func(route *Route, params map[string]string) bool {
currentPath := GetCurrentPath()
return strings.HasPrefix(currentPath, prefix)
}
}
// CombineGuards combines multiple guards with AND logic
func CombineGuards(guards ...RouteGuard) RouteGuard {
return func(route *Route, params map[string]string) bool {
for _, guard := range guards {
if !guard(route, params) {
return false
}
}
return true
}
}
// AnyGuard combines multiple guards with OR logic
func AnyGuard(guards ...RouteGuard) RouteGuard {
return func(route *Route, params map[string]string) bool {
for _, guard := range guards {
if guard(route, params) {
return true
}
}
return false
}
}
// Example usage patterns:
// AdminOnlyGuard - combines authentication and role checks
func AdminOnlyGuard(isAuth func() bool, userRole func() string) RouteGuard {
return CombineGuards(
AuthGuard(isAuth),
RoleGuard(userRole, "admin"),
)
}
// NumericIdGuard - validates that :id parameter is numeric
func NumericIdGuard() RouteGuard {
return ParamValidationGuard(func(params map[string]string) bool {
if id, exists := params["id"]; exists {
// Simple numeric check
for _, char := range id {
if char < '0' || char > '9' {
return false
}
}
return len(id) > 0
}
return true // No id param, so validation passes
})
}

63
navigation/history.go Normal file
View File

@@ -0,0 +1,63 @@
//build +js,wasm
package navigation
import (
"syscall/js"
)
type HistoryManager struct {
router *Router
window js.Value
}
func NewHistoryManager(router *Router) *HistoryManager {
return &HistoryManager{
router: router,
window: js.Global().Get("window"),
}
}
func (h *HistoryManager) Start() {
// Handle initial navigation
currentPath := h.getCurrentPath()
h.router.Navigate(currentPath)
// Listen for browser back/forward events
h.window.Call("addEventListener", "popstate", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
path := h.getCurrentPath()
h.router.Navigate(path)
return nil
}))
}
func (h *HistoryManager) PushState(path string) {
h.window.Get("history").Call("pushState", nil, "", path)
h.router.Navigate(path)
}
func (h *HistoryManager) ReplaceState(path string) {
h.window.Get("history").Call("replaceState", nil, "", path)
h.router.Navigate(path)
}
func (h *HistoryManager) Back() {
h.window.Get("history").Call("back")
}
func (h *HistoryManager) Forward() {
h.window.Get("history").Call("forward")
}
func (h *HistoryManager) getCurrentPath() string {
location := h.window.Get("location")
pathname := location.Get("pathname").String()
search := location.Get("search").String()
path := pathname
if search != "" {
path += search
}
return path
}

157
navigation/router.go Normal file
View File

@@ -0,0 +1,157 @@
package navigation
import (
"strings"
"git.flowmade.one/flowmade-one/iris/reactive"
"git.flowmade.one/flowmade-one/iris/ui"
)
type RouteGuard func(route *Route, params map[string]string) bool
type Route struct {
Path string
Handler func(params map[string]string) ui.View
Guards []RouteGuard
parts []string
params []string
}
type Router struct {
routes []Route
currentPath *reactive.Signal[string]
currentView *reactive.Signal[ui.View]
notFound func() ui.View
}
func NewRouter(routes []Route) *Router {
currentPath := reactive.NewSignal("")
currentView := reactive.NewSignal(ui.NewView())
router := &Router{
routes: make([]Route, len(routes)),
currentPath: &currentPath,
currentView: &currentView,
notFound: defaultNotFound,
}
// Parse route patterns
for i, route := range routes {
parsedRoute := route
parsedRoute.parts, parsedRoute.params = parseRoute(route.Path)
router.routes[i] = parsedRoute
}
return router
}
func (r *Router) SetNotFoundHandler(handler func() ui.View) {
r.notFound = handler
}
func (r *Router) Navigate(path string) {
r.currentPath.Set(path)
r.updateView(path)
}
func (r *Router) GetCurrentPath() string {
return r.currentPath.Get()
}
func (r *Router) GetCurrentView() ui.View {
return r.currentView.Get()
}
func (r *Router) updateView(path string) {
for _, route := range r.routes {
if params := r.matchRoute(&route, path); params != nil {
// Check guards
if r.checkGuards(&route, params) {
view := route.Handler(params)
r.currentView.Set(view)
return
}
}
}
// No route matched or guards failed
r.currentView.Set(r.notFound())
}
func (r *Router) matchRoute(route *Route, path string) map[string]string {
// Strip query parameters for route matching
if queryIndex := strings.Index(path, "?"); queryIndex != -1 {
path = path[:queryIndex]
}
pathParts := strings.Split(strings.Trim(path, "/"), "/")
if len(pathParts) == 1 && pathParts[0] == "" {
pathParts = []string{} // Handle root path
}
if len(pathParts) != len(route.parts) {
return nil
}
params := make(map[string]string)
paramIndex := 0
for i, routePart := range route.parts {
if strings.HasPrefix(routePart, ":") {
// This is a parameter
if paramIndex < len(route.params) {
params[route.params[paramIndex]] = pathParts[i]
paramIndex++
}
} else {
// Static part must match exactly
if routePart != pathParts[i] {
return nil
}
}
}
return params
}
func (r *Router) checkGuards(route *Route, params map[string]string) bool {
for _, guard := range route.Guards {
if !guard(route, params) {
return false
}
}
return true
}
func parseRoute(path string) ([]string, []string) {
var params []string
parts := strings.Split(strings.Trim(path, "/"), "/")
if len(parts) == 1 && parts[0] == "" {
parts = []string{} // Handle root path
}
// Find parameters like :id, :name, etc.
for _, part := range parts {
if strings.HasPrefix(part, ":") {
paramName := part[1:] // Remove the ':'
// Simple validation: param name should start with letter
if len(paramName) > 0 && isLetter(paramName[0]) {
params = append(params, paramName)
}
}
}
return parts, params
}
// isLetter checks if a byte is a letter
func isLetter(b byte) bool {
return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z')
}
func defaultNotFound() ui.View {
return ui.VerticalGroup(
ui.TextFromString("404 - Page Not Found").Color("#ff4444"),
ui.TextFromString("The requested page could not be found.").Color("#666"),
)
}