diff --git a/examples/todo/main.go b/examples/todo/main.go new file mode 100644 index 0000000..91bba00 --- /dev/null +++ b/examples/todo/main.go @@ -0,0 +1,195 @@ +//go:build js && wasm + +package main + +import ( + "fmt" + "syscall/js" + + "git.flowmade.one/flowmade-one/iris/reactive" + "git.flowmade.one/flowmade-one/iris/ui" +) + +// Todo represents a single todo item +type Todo struct { + ID int + Text string + Completed bool +} + +func main() { + // Signal holding the list of todos + todos := reactive.NewSignal([]Todo{}) + + // Signal for the input field + inputText := reactive.NewSignal("") + + // Counter for generating unique IDs + nextID := reactive.NewSignal(1) + + // Create the main app view + view := ui.VerticalGroup( + // Title + ui.TextFromString("Todo List").Padding("16px 0"), + + // Input row: text input + add button + inputRow(&inputText, func() { + text := inputText.Get() + if text == "" { + return + } + + // Add new todo + id := nextID.Get() + nextID.Set(id + 1) + + currentTodos := todos.Get() + newTodos := append(currentTodos, Todo{ + ID: id, + Text: text, + Completed: false, + }) + todos.Set(newTodos) + + // Clear input + inputText.Set("") + }), + + // Todo list + todoList(&todos), + + // Summary text showing completed count + ui.TextFromFunction(func() string { + allTodos := todos.Get() + if len(allTodos) == 0 { + return "No todos yet" + } + + completed := 0 + for _, t := range allTodos { + if t.Completed { + completed++ + } + } + return fmt.Sprintf("%d of %d completed", completed, len(allTodos)) + }).Padding("16px 0"), + ).MaxWidth("400px").Padding("24px") + + ui.NewApp(view) + + select {} +} + +// inputRow creates the input field and add button +func inputRow(inputText *reactive.Signal[string], onAdd func()) ui.View { + row := ui.NewView() + row.Display("flex").Gap("8px").Width("100%") + + // Text input + input := ui.TextInput(inputText, "What needs to be done?") + input.Width("100%") + + // Handle Enter key to add todo + input.Element().OnWithEvent("keypress", func(event js.Value) { + if event.Get("key").String() == "Enter" { + onAdd() + } + }) + + // Add button + addBtn := ui.Button(onAdd, ui.TextFromString("Add")) + addBtn.Padding("8px 16px") + + row.Child(input) + row.Child(addBtn) + + return row +} + +// todoList renders the list of todos reactively +func todoList(todos *reactive.Signal[[]Todo]) ui.View { + container := ui.NewView() + container.Width("100%") + + // Effect that re-renders the list when todos change + reactive.NewEffect(func() { + allTodos := todos.Get() + + // Clear existing children + container.Element().ClearChildren() + + // Render each todo item + for _, todo := range allTodos { + item := todoItem(todo, todos) + container.Child(item) + } + + // Show empty state if no todos + if len(allTodos) == 0 { + emptyText := ui.TextFromString("Add your first todo above") + emptyText.Color("#666").Padding("16px 0").TextAlign("center") + container.Child(emptyText) + } + }) + + return container +} + +// todoItem renders a single todo item with toggle and delete +func todoItem(todo Todo, todos *reactive.Signal[[]Todo]) ui.View { + row := ui.NewView() + row.Display("flex").Gap("12px").Padding("8px 0").Width("100%").AlignItems("center") + row.BorderBottom("1px solid #eee") + + // Completed checkbox using public ui.RawCheckbox + checkbox := ui.RawCheckbox(todo.Completed, func(isChecked bool) { + toggleTodo(todos, todo.ID, isChecked) + }) + + // Todo text with strikethrough if completed + textView := ui.NewView() + textView.Display("flex").Width("100%") + text := ui.Span(todo.Text) + if todo.Completed { + text.TextDecoration("line-through").Color("#999") + } + textView.Child(text) + + // Delete button + deleteBtn := ui.Button(func() { + deleteTodo(todos, todo.ID) + }, ui.TextFromString("X")) + deleteBtn.Padding("4px 8px") + + row.Child(checkbox) + row.Child(textView) + row.Child(deleteBtn) + + return row +} + +// toggleTodo updates the completed status of a todo +func toggleTodo(todos *reactive.Signal[[]Todo], id int, completed bool) { + currentTodos := todos.Get() + newTodos := make([]Todo, len(currentTodos)) + for i, t := range currentTodos { + if t.ID == id { + newTodos[i] = Todo{ID: t.ID, Text: t.Text, Completed: completed} + } else { + newTodos[i] = t + } + } + todos.Set(newTodos) +} + +// deleteTodo removes a todo from the list +func deleteTodo(todos *reactive.Signal[[]Todo], id int) { + currentTodos := todos.Get() + newTodos := make([]Todo, 0, len(currentTodos)) + for _, t := range currentTodos { + if t.ID != id { + newTodos = append(newTodos, t) + } + } + todos.Set(newTodos) +} diff --git a/ui/input.go b/ui/input.go index 0410301..f82a362 100644 --- a/ui/input.go +++ b/ui/input.go @@ -205,4 +205,23 @@ func NumberInput(value *reactive.Signal[float64], placeholder string) View { }) return View{input} -} \ No newline at end of file +} +// RawCheckbox creates a plain checkbox input without a label wrapper. +// The onChange callback is called with the new checked state when the user clicks. +func RawCheckbox(checked bool, onChange func(bool)) View { + checkbox := element.NewElement("input") + checkbox.Attr("type", "checkbox") + + // Set initial state + if checked { + checkbox.Set("checked", true) + } + + // Call onChange when user clicks + checkbox.On("change", func() { + newValue := checkbox.Get("checked").Bool() + onChange(newValue) + }) + + return View{checkbox} +} diff --git a/ui/modifiers.go b/ui/modifiers.go index 0b4bcdd..0ae4b14 100644 --- a/ui/modifiers.go +++ b/ui/modifiers.go @@ -170,3 +170,8 @@ func (v View) PointerEvents(value string) View { v.e.PointerEvents(value) return v } + +func (v View) TextDecoration(value string) View { + v.e.Get("style").Call("setProperty", "text-decoration", value) + return v +} diff --git a/ui/text.go b/ui/text.go index e5a5590..ea6fd6b 100644 --- a/ui/text.go +++ b/ui/text.go @@ -21,3 +21,10 @@ func TextFromFunction(fn func() string) View { return View{textNode} } + +// Span creates a span element with the given text content. +func Span(text string) View { + v := View{element.NewElement("span")} + v.e.Set("textContent", text) + return v +}