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:
90
navigation/components.go
Normal file
90
navigation/components.go
Normal 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
69
navigation/core.go
Normal 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
91
navigation/guards.go
Normal 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
63
navigation/history.go
Normal 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
157
navigation/router.go
Normal 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: ¤tPath,
|
||||
currentView: ¤tView,
|
||||
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"),
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user