diff --git a/core/src/scalive/Dyn.scala b/core/src/scalive/Dyn.scala index 600a251..512e152 100644 --- a/core/src/scalive/Dyn.scala +++ b/core/src/scalive/Dyn.scala @@ -37,7 +37,6 @@ sealed trait Dyn[T]: private[scalive] def callOnEveryChild(f: T => Unit): Unit extension [T](parent: Dyn[List[T]]) - // TODO fix def splitBy[Key](key: T => Key)(project: (Key, Dyn[T]) => HtmlElement): Mod = Mod.Content.DynSplit( new SplitVar( diff --git a/example/src/TodoLiveView.scala b/example/src/TodoLiveView.scala index a54893a..4231706 100644 --- a/example/src/TodoLiveView.scala +++ b/example/src/TodoLiveView.scala @@ -1,38 +1,42 @@ import TodoLiveView.* -import monocle.syntax.all.* import scalive.* import zio.* import zio.stream.ZStream class TodoLiveView() extends LiveView[Msg, Model]: - def init = Model(List(Todo(99, "Buy eggs"))) + def init = Model( + List( + Todo(99, "Buy eggs"), + Todo(1, "Wash dishes", true) + ) + ) def update(model: Model) = case Msg.Add(text) => - val nextId = model.todos.maxByOption(_.id).map(_.id).getOrElse(1) + 1 - model - .focus(_.todos) - .modify(_.appended(Todo(nextId, text))) + val nextId = model.items.maxByOption(_.id).map(_.id).getOrElse(1) + 1 + model.copy(items = model.items.appended(Todo(nextId, text))) + case Msg.Edit(id) => + model.updateItem(id, _.copy(editing = true)) + case Msg.Update(id, text) => + model.updateItem(id, _.copy(text = text, editing = false)) case Msg.Remove(id) => - model - .focus(_.todos) - .modify(_.filterNot(_.id == id)) + model.copy(items = model.items.filterNot(_.id == id)) case Msg.ToggleCompletion(id) => - model - .focus(_.todos) - .modify( - _.map(todo => if todo.id == id then todo.copy(completed = todo.completed) else todo) - ) + model.updateItem(id, todo => todo.copy(completed = !todo.completed)) + case Msg.SetFilter(filter) => + model.copy(filter = filter) + case Msg.RemoveCompleted => + model.copy(items = model.items.filterNot(_.completed)) def view(model: Dyn[Model]) = div( cls := "mx-auto card bg-base-100 max-w-2xl shadow-xl space-y-6 p-6", div( cls := "card-body", - h1(cls := "card-title", "Todos"), + h1(cls := "card-title text-3xl", "Todos"), form( - cls := "flex items-center gap-3", + cls := "mt-6 flex items-center gap-3", phx.onSubmit(p => Msg.Add(p("todo-name"))), input( cls := "input input-bordered grow", @@ -44,7 +48,7 @@ class TodoLiveView() extends LiveView[Msg, Model]: ), ul( cls := "divide-y divide-base-200", - model(_.todos).splitBy(_.id)((id, todo) => + model(_.filteredItems).splitBy(_.id)((id, todo) => li( cls := "py-3 flex items-center gap-3", input( @@ -53,7 +57,25 @@ class TodoLiveView() extends LiveView[Msg, Model]: checked := todo(_.completed), phx.onClick(Msg.ToggleCompletion(id)) ), - todo(_.text), + todo.whenNot(_.editing)( + div( + cls := "truncate cursor-text w-full", + phx.onClick(Msg.Edit(id)), + span( + cls := todo(t => if t.completed then "line-through opacity-60" else ""), + todo(_.text) + ) + ) + ), + todo.when(_.editing)( + input( + tpe := "text", + cls := "input input-bordered w-full", + value := todo(_.text), + phx.onMounted(JS.focus()), + phx.onBlur.withValue(Msg.Update(id, _)) + ) + ), button( cls := "btn btn-ghost btn-sm text-error", phx.onClick(Msg.Remove(id)), @@ -61,6 +83,43 @@ class TodoLiveView() extends LiveView[Msg, Model]: ) ) ) + ), + div( + cls := "card-actions flex flex-wrap items-center gap-3 justify-between", + div( + cls := "join", + button( + cls := model(_.filter match + case Filter.All => "btn btn-sm join-item btn-active" + case _ => "btn btn-sm join-item"), + phx.onClick(Msg.SetFilter(Filter.All)), + "All" + ), + button( + cls := model(_.filter match + case Filter.Active => "btn btn-sm join-item btn-active" + case _ => "btn btn-sm join-item"), + phx.onClick(Msg.SetFilter(Filter.Active)), + "Active" + ), + button( + cls := model(_.filter match + case Filter.Completed => "btn btn-sm join-item btn-active" + case _ => "btn btn-sm join-item"), + phx.onClick(Msg.SetFilter(Filter.Completed)), + "Completed" + ) + ), + span( + cls := "badge badge-outline", + model(m => s"${m.itemsLeft} item${if m.itemsLeft == 1 then "" else "s"} left") + ), + button( + cls := "btn btn-sm btn-outline", + disabled := model(_.completedCount == 0), + phx.onClick(Msg.RemoveCompleted), + model(m => s"Clear completed (${m.completedCount})") + ) ) ) ) @@ -72,13 +131,33 @@ object TodoLiveView: enum Msg: case Add(text: String) + case Edit(id: Int) + case Update(id: Int, text: String) case Remove(id: Int) case ToggleCompletion(id: Int) + case SetFilter(value: Filter) + case RemoveCompleted final case class Model( - todos: List[Todo] = List.empty, + items: List[Todo] = List.empty, inputText: String = "", - filter: Filter = Filter.All) - final case class Todo(id: Int, text: String, completed: Boolean = false) + filter: Filter = Filter.All): + def itemsLeft = items.count(!_.completed) + def completedCount = items.count(_.completed) + def filteredItems = items.filter(item => + filter match + case Filter.All => true + case Filter.Active => !item.completed + case Filter.Completed => item.completed + ) + def updateItem(id: Int, f: Todo => Todo) = + import monocle.syntax.all.* + this + .focus(_.items).each.filter(_.id == id) + .modify(f) + + final case class Todo(id: Int, text: String, completed: Boolean = false, editing: Boolean = false) + enum Filter: case All, Active, Completed +end TodoLiveView