[Issue #5] Todo list example #16

Merged
HugoNijhuis merged 2 commits from issue-5-todo-list-example into main 2026-01-09 16:29:58 +00:00
4 changed files with 227 additions and 1 deletions

195
examples/todo/main.go Normal file
View File

@@ -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)
}

View File

@@ -205,4 +205,23 @@ func NumberInput(value *reactive.Signal[float64], placeholder string) View {
})
return View{input}
}
}
// 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}
}

View File

@@ -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
}

View File

@@ -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
}