Filtros y Busqueda en Tiempo Real
La app ya crea, edita y elimina tareas. Ahora agregamos filtros por estado (todos, activos, completados) y busqueda en tiempo real que filtra mientras el usuario escribe. Tambien actualizamos el contador de tareas pendientes con OOB swaps.
Filtros por Estado
Los filtros son botones que piden al servidor la lista filtrada. Cada boton envia un query param diferente.
Componente de Filtros
package views
templ TodoFilters(active string) {
<div class="todo-filters" id="todo-filters">
<button class={ "filter-btn", templ.KV("active", active == "all") }
hx-get="/todos?filter=all"
hx-target="#todo-list"
hx-swap="innerHTML">
Todos
</button>
<button class={ "filter-btn", templ.KV("active", active == "active") }
hx-get="/todos?filter=active"
hx-target="#todo-list"
hx-swap="innerHTML">
Activos
</button>
<button class={ "filter-btn", templ.KV("active", active == "completed") }
hx-get="/todos?filter=completed"
hx-target="#todo-list"
hx-swap="innerHTML">
Completados
</button>
</div>
}
La clase active se agrega al boton del filtro actual usando templ.KV.
Actualizar la Vista Principal
Agregamos los filtros y la busqueda a IndexPage:
templ IndexPage(todos []models.Todo) {
@Layout("Todo List") {
@TodoForm()
@SearchBar()
@TodoFilters("all")
<div id="todo-list">
@TodoList(todos)
</div>
<div id="todo-count">
@TodoCount(countPending(todos))
</div>
}
}
Handler con Filtro
Modificamos el handler List para aceptar el query param filter:
func (h *TodoHandler) List(w http.ResponseWriter, r *http.Request) {
filter := r.URL.Query().Get("filter")
todos := h.store.GetAll()
filtered := filterTodos(todos, filter)
views.TodoList(filtered).Render(r.Context(), w)
}
func filterTodos(todos []models.Todo, filter string) []models.Todo {
if filter == "" || filter == "all" {
return todos
}
var result []models.Todo
for _, t := range todos {
switch filter {
case "active":
if !t.Completed {
result = append(result, t)
}
case "completed":
if t.Completed {
result = append(result, t)
}
}
}
return result
}
El filtrado es simple: si es “all” o vacio, devolver todo. Si es “active”, solo los no completados. Si es “completed”, solo los completados.
Busqueda en Tiempo Real
La busqueda filtra tareas mientras el usuario escribe, con un delay para no saturar el servidor.
Componente de Busqueda
package views
templ SearchBar() {
<div class="search-bar">
<input type="search"
name="q"
placeholder="Buscar tareas..."
hx-get="/todos/search"
hx-target="#todo-list"
hx-swap="innerHTML"
hx-trigger="keyup changed delay:300ms"
hx-indicator="#search-spinner"
autocomplete="off"/>
<span id="search-spinner" class="htmx-indicator">
Buscando...
</span>
</div>
}
Atributos clave:
hx-trigger="keyup changed delay:300ms": espera 300ms despues de que el usuario deja de escribirchanged: solo dispara si el valor realmente cambiohx-indicator="#search-spinner": muestra un indicador de carga
Handler de Busqueda
func (h *TodoHandler) Search(w http.ResponseWriter, r *http.Request) {
query := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("q")))
todos := h.store.GetAll()
if query == "" {
views.TodoList(todos).Render(r.Context(), w)
return
}
var results []models.Todo
for _, t := range todos {
if strings.Contains(strings.ToLower(t.Title), query) {
results = append(results, t)
}
}
views.TodoList(results).Render(r.Context(), w)
}
La busqueda es case-insensitive: convertimos tanto el query como el titulo a minusculas antes de comparar.
Ruta de Busqueda
mux.HandleFunc("GET /todos/search", h.Search)
Contador con OOB Swap
Cuando se agrega, elimina o cambia el estado de una tarea, el contador de pendientes debe actualizarse. Usamos Out of Band (OOB) swaps para actualizar multiples partes del DOM con una sola respuesta.
Como Funciona OOB
Normalmente HTMX pone la respuesta en un solo target. Con hx-swap-oob="true", un elemento dentro de la respuesta se coloca en su posicion por ID, fuera del flujo normal.
Actualizar el Handler Create
func (h *TodoHandler) Create(w http.ResponseWriter, r *http.Request) {
title := strings.TrimSpace(r.FormValue("title"))
if title == "" {
http.Error(w, "El titulo es requerido", http.StatusBadRequest)
return
}
todo := h.store.Add(title)
todos := h.store.GetAll()
pending := countPending(todos)
views.TodoItemWithCount(todo, pending).Render(r.Context(), w)
}
Componente con OOB
package views
import "todo-app/models"
templ TodoItemWithCount(todo models.Todo, pending int) {
@TodoItem(todo)
<div id="todo-count" hx-swap-oob="true">
@TodoCount(pending)
</div>
}
La respuesta contiene dos cosas:
- El
TodoItemque va al target normal (#todo-list) - El
TodoCountconhx-swap-oob="true"que se coloca en#todo-count
HTMX procesa ambos: inserta el item en la lista y actualiza el contador, todo en un solo request.
Helper countPending en Go
Para usar countPending tanto en templates como en handlers:
func countPending(todos []models.Todo) int {
count := 0
for _, t := range todos {
if !t.Completed {
count++
}
}
return count
}
Boton Limpiar Completados
Un boton que elimina todas las tareas completadas de un golpe:
package views
templ ClearCompleted() {
<button class="btn-clear"
hx-delete="/todos/completed"
hx-target="#todo-list"
hx-swap="innerHTML"
hx-confirm="Eliminar todas las tareas completadas?">
Limpiar completados
</button>
}
Handler DELETE /todos/completed
func (h *TodoHandler) ClearCompleted(w http.ResponseWriter, r *http.Request) {
h.store.DeleteCompleted()
todos := h.store.GetAll()
pending := countPending(todos)
views.TodoListWithCount(todos, pending).Render(r.Context(), w)
}
Metodo DeleteCompleted en el Store
func (s *TodoStore) DeleteCompleted() {
s.mu.Lock()
defer s.mu.Unlock()
var active []Todo
for _, t := range s.todos {
if !t.Completed {
active = append(active, t)
}
}
s.todos = active
}
Respuesta con OOB para el Contador
package views
import "todo-app/models"
templ TodoListWithCount(todos []models.Todo, pending int) {
@TodoList(todos)
<div id="todo-count" hx-swap-oob="true">
@TodoCount(pending)
</div>
}
Ruta
mux.HandleFunc("DELETE /todos/completed", h.ClearCompleted)
Aplicar OOB a Toggle y Delete
El mismo patron OOB se aplica a toggle y delete para mantener el contador sincronizado:
func (h *TodoHandler) Toggle(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
http.Error(w, "ID invalido", http.StatusBadRequest)
return
}
todo, ok := h.store.Toggle(id)
if !ok {
http.Error(w, "Todo no encontrado", http.StatusNotFound)
return
}
todos := h.store.GetAll()
pending := countPending(todos)
views.TodoItemWithCount(todo, pending).Render(r.Context(), w)
}
Resumen
| Feature | Atributo HTMX | Mecanismo |
|---|---|---|
| Filtros | hx-get con query param | Reemplaza lista completa |
| Busqueda | hx-trigger="keyup changed delay:300ms" | Debounce automatico |
| Indicador | hx-indicator | Muestra/oculta spinner |
| Contador | hx-swap-oob="true" | Actualiza fuera del target |
| Limpiar | hx-delete con hx-confirm | Elimina batch con confirmacion |
OOB swaps son una de las caracteristicas mas poderosas de HTMX: permiten actualizar multiples partes de la pagina con una sola respuesta del servidor.
<— Capitulo 11: Editar y Eliminar | Capitulo 13: Estilos y UX —>