Busca com Autocomplete¶
Construa uma busca de países com filtragem em tempo real: conforme o usuário
digita, o widget Autocomplete exibe sugestões; pílulas Chip permitem restringir
por continente antes mesmo de digitar. 🔍
Ao final deste tutorial você terá um app completo que exercita Autocomplete,
Chip, Wrap, Column, Row, Text e Button — com dois handlers tipados
(on_change e on_select) e estado derivado recalculado a cada interação.
O problema¶
Caixas de busca com autocomplete são onipresentes, mas implementá-las corretamente envolve três desafios simultâneos:
- Filtragem ao vivo — a lista de sugestões muda a cada tecla.
- Seleção vs. digitação — confirmar uma sugestão é diferente de continuar digitando; o estado precisa distinguir os dois.
- Filtragem por categoria — o usuário quer restringir o universo de resultados antes de digitar, usando pílulas clicáveis.
O tempestweb resolve tudo isso com estado explícito e dois eventos tipados:
TextChangeEvent (cada keystroke) e SelectEvent (item escolhido).
O que você vai exercitar
Autocomplete— campo com lista de sugestões dinâmica.Chip— pílula clicável com estadoselectedpara filtros de categoria.Wrap— layout que flui as pílulas automaticamente quando o espaço é insuficiente.TextChangeEventeSelectEvent— os dois eventos tipados doAutocomplete.- Estado derivado com
recompute()— sugestões recalculadas sempre que query ou categoria mudam. - Closures nos handlers de categoria —
_make_chipcaptura ocatcorreto para cada pílula.
Pré-requisitos¶
Antes de continuar, certifique-se de ter feito a
Instalação e lido o
Tutorial do Counter — este exemplo assume que você já
conhece Column, Row, Text, App, make_state, view e o ciclo de
set_state.
O app completo¶
Este é o código exato de
examples/search-autocomplete/app.py.
Copie, rode, e depois leia a explicação peça por peça.
"""Search with autocomplete — exercises Autocomplete, Chip, and dynamic filtering.
A realistic country-search widget: as the user types, the
:class:`~tempestweb._core.widgets.Autocomplete` widget narrows the suggestion
list in real time. Selecting a suggestion commits it as the active choice and
shows it as a :class:`~tempestweb._core.components.Chip` below the field. The
user can clear the committed choice with a button and start over.
The demo also showcases *category filtering*: three
:class:`~tempestweb._core.components.Chip` pills let the user restrict suggestions
to a continent (All / Americas / Europe), so the autocomplete's ``options``
list changes whenever the query *or* the category filter changes.
Run in either mode — the ``view`` function is transport-agnostic::
tempestweb dev --mode wasm # Python in the browser (Pyodide)
tempestweb dev --mode server # Python on the server (FastAPI + WebSocket)
"""
from __future__ import annotations
from dataclasses import dataclass, field
from tempestweb._core import App, Style, Widget
from tempestweb._core.components import Chip
from tempestweb._core.style import Edge
from tempestweb._core.widgets import (
Autocomplete,
Button,
Column,
Row,
Text,
Wrap,
)
from tempestweb._core.widgets.events import SelectEvent, TextChangeEvent
# ---------------------------------------------------------------------------
# Data catalog
# ---------------------------------------------------------------------------
_COUNTRIES: list[tuple[str, str]] = [
("Argentina", "Americas"),
("Bolivia", "Americas"),
("Brazil", "Americas"),
("Canada", "Americas"),
("Chile", "Americas"),
("Colombia", "Americas"),
("Ecuador", "Americas"),
("Mexico", "Americas"),
("Paraguay", "Americas"),
("Peru", "Americas"),
("United States", "Americas"),
("Uruguay", "Americas"),
("Venezuela", "Americas"),
("Austria", "Europe"),
("Belgium", "Europe"),
("Czech Republic", "Europe"),
("Denmark", "Europe"),
("Finland", "Europe"),
("France", "Europe"),
("Germany", "Europe"),
("Greece", "Europe"),
("Hungary", "Europe"),
("Ireland", "Europe"),
("Italy", "Europe"),
("Netherlands", "Europe"),
("Norway", "Europe"),
("Poland", "Europe"),
("Portugal", "Europe"),
("Romania", "Europe"),
("Spain", "Europe"),
("Sweden", "Europe"),
("Switzerland", "Europe"),
("United Kingdom", "Europe"),
]
_CATEGORIES: list[str] = ["All", "Americas", "Europe"]
_MAX_SUGGESTIONS: int = 8
def _filter_suggestions(query: str, category: str) -> list[str]:
"""Return up to ``_MAX_SUGGESTIONS`` country names matching the current query.
Matching is case-insensitive and substring-based so partial strings like
``"bra"`` find ``"Brazil"`` immediately. The ``category`` filter limits the
pool to a single continent when it is not ``"All"``.
Args:
query: The current text typed into the search field.
category: The active category filter — ``"All"`` disables the filter.
Returns:
A list of at most :data:`_MAX_SUGGESTIONS` matching country names in
alphabetical order.
"""
q = query.strip().lower()
matches: list[str] = []
for name, continent in _COUNTRIES:
if category != "All" and continent != category:
continue
if not q or q in name.lower():
matches.append(name)
return sorted(matches)[:_MAX_SUGGESTIONS]
# ---------------------------------------------------------------------------
# State
# ---------------------------------------------------------------------------
@dataclass
class SearchState:
"""State for the search-autocomplete example.
Attributes:
query: The live text in the autocomplete field.
category: The active continent filter.
committed: The country name that was explicitly selected, or ``""``
when nothing has been confirmed yet.
suggestions: The current filtered suggestion list derived from
``query`` and ``category``; recomputed on every relevant mutation.
"""
query: str = ""
category: str = "All"
committed: str = ""
suggestions: list[str] = field(default_factory=list)
def recompute(self) -> None:
"""Refresh :attr:`suggestions` from the current query and category.
Called internally after every mutation that changes the filter state.
"""
self.suggestions = _filter_suggestions(self.query, self.category)
def make_state() -> SearchState:
"""Build the initial state with a full suggestion list.
Returns:
A fresh :class:`SearchState` with all countries visible and no active
query or selection.
"""
s = SearchState()
s.recompute()
return s
# ---------------------------------------------------------------------------
# View
# ---------------------------------------------------------------------------
def view(app: App[SearchState]) -> Widget:
"""Render the search-autocomplete UI from the current state.
The view is a vertical column with three sections:
1. **Category filter** — three :class:`Chip` pills to narrow by continent.
2. **Autocomplete field** — the live-filtered text field.
3. **Result area** — either a confirmation card for the committed country
or a placeholder prompt.
Args:
app: The application handle exposing ``state`` and ``set_state``.
Returns:
The widget tree for the current state.
"""
s: SearchState = app.state
# -- handlers ----------------------------------------------------------
def on_query_change(event: TextChangeEvent) -> None:
"""Update the live query and refresh suggestions on each keystroke.
Args:
event: The text-change event carrying the new input value.
"""
def mutate(st: SearchState) -> None:
st.query = event.value
st.committed = ""
st.recompute()
app.set_state(mutate)
def on_suggestion_select(event: SelectEvent) -> None:
"""Commit the selected suggestion and clear the live query.
Args:
event: The select event carrying the chosen suggestion value.
"""
def mutate(st: SearchState) -> None:
st.committed = event.value
st.query = event.value
st.suggestions = []
app.set_state(mutate)
def on_clear() -> None:
"""Reset the query, committed selection and suggestions."""
def mutate(st: SearchState) -> None:
st.query = ""
st.committed = ""
st.recompute()
app.set_state(mutate)
def on_category(cat: str) -> None:
"""Switch the active category filter and refresh suggestions.
Args:
cat: The category label to activate.
"""
def mutate(st: SearchState) -> None:
st.category = cat
st.committed = ""
st.query = ""
st.recompute()
app.set_state(mutate)
# -- category chips ----------------------------------------------------
def _make_chip(cat: str) -> Widget:
"""Build one category-filter chip for ``cat``.
Args:
cat: The category label this chip represents.
Returns:
A :class:`Chip` widget bound to ``on_category``.
"""
def click() -> None:
on_category(cat)
return Chip(
key=f"cat-{cat}",
label=cat,
selected=(s.category == cat),
on_click=click,
)
category_chips: list[Widget] = [_make_chip(cat) for cat in _CATEGORIES]
# -- result area -------------------------------------------------------
if s.committed:
result_children: list[Widget] = [
Text(
key="chosen-label",
content="Selected country:",
style=Style(font_size=13.0),
),
Row(
key="chosen-row",
style=Style(gap=8.0),
children=[
Text(
key="chosen-value",
content=s.committed,
style=Style(font_size=18.0),
),
Button(
key="clear-btn",
label="Clear",
on_click=on_clear,
style=Style(
padding=Edge.symmetric(vertical=4.0, horizontal=10.0),
radius=6.0,
),
),
],
),
]
else:
result_children = [
Text(
key="prompt",
content="Type a country name or pick one from the suggestions.",
style=Style(font_size=14.0),
),
]
# -- root layout -------------------------------------------------------
return Column(
key="root",
style=Style(gap=16.0, padding=Edge.all(24.0)),
children=[
Text(
key="heading",
content="Country Search",
style=Style(font_size=22.0),
),
Text(
key="subheading",
content="Filter by continent, then search:",
style=Style(font_size=14.0),
),
Wrap(
key="categories",
style=Style(gap=8.0),
children=category_chips,
),
Autocomplete(
key="search",
value=s.query,
placeholder="e.g. Brazil, France…",
options=s.suggestions,
on_change=on_query_change,
on_select=on_suggestion_select,
),
Column(
key="result",
style=Style(gap=8.0, padding=Edge.all(12.0), radius=10.0),
children=result_children,
),
],
)
Explicando peça por peça¶
1. O catálogo de dados¶
_COUNTRIES: list[tuple[str, str]] = [
("Argentina", "Americas"),
("Brazil", "Americas"),
("France", "Europe"),
# ...
]
_CATEGORIES: list[str] = ["All", "Americas", "Europe"]
_MAX_SUGGESTIONS: int = 8
Os dados ficam fora de qualquer classe — são constantes do módulo, imutáveis.
_COUNTRIES é uma lista de tuplas (nome, continente). _MAX_SUGGESTIONS
garante que a lista de sugestões não cresça indefinidamente, tornando o DOM
pesado.
Dica
Em um app real você buscaria esses dados de uma API ou banco de dados. A
função _filter_suggestions seria async e chamaria um repositório. A
estrutura do view e do estado permanece idêntica — só o origin dos dados
muda.
2. A função de filtragem pura¶
def _filter_suggestions(query: str, category: str) -> list[str]:
q = query.strip().lower()
matches: list[str] = []
for name, continent in _COUNTRIES:
if category != "All" and continent != category:
continue
if not q or q in name.lower():
matches.append(name)
return sorted(matches)[:_MAX_SUGGESTIONS]
Dois critérios combinados:
- Continente: se
category != "All", exclui países de outros continentes. - Query:
q in name.lower()faz correspondência por substring (case-insensitive)."bra"encontra"Brazil". Seqé vazio, todos os países do continente aparecem.
O resultado é ordenado alfabeticamente e truncado em _MAX_SUGGESTIONS.
Nota
A função não sabe nada sobre SearchState nem sobre App — ela é
completamente pura e testável de forma isolada com pytest.
3. O estado com recompute()¶
@dataclass
class SearchState:
query: str = ""
category: str = "All"
committed: str = ""
suggestions: list[str] = field(default_factory=list)
def recompute(self) -> None:
self.suggestions = _filter_suggestions(self.query, self.category)
suggestions é estado derivado: sempre calculado a partir de query e
category. Em vez de recalcular em cada handler separadamente, o método
recompute() centraliza essa lógica. Qualquer handler que mudar query ou
category chama recompute() antes de encerrar a mutação.
Estado derivado vs. estado independente
query, category e committed são estados independentes (o usuário
os controla diretamente). suggestions é derivado — nunca deve ser
alterado diretamente; sempre via recompute(). Esse padrão evita
inconsistências onde suggestions e query ficam "fora de sincronia".
make_state() chama recompute() imediatamente, então ao abrir o app o
campo já exibe sugestões (todos os países, sem filtro).
4. Dois eventos distintos: on_change vs. on_select¶
O Autocomplete expõe dois handlers com semânticas diferentes:
Autocomplete(
key="search",
value=s.query,
placeholder="e.g. Brazil, France…",
options=s.suggestions,
on_change=on_query_change,
on_select=on_suggestion_select,
)
| Handler | Evento | Quando dispara |
|---|---|---|
on_change |
TextChangeEvent |
A cada tecla digitada no campo |
on_select |
SelectEvent |
Quando o usuário clica em uma sugestão |
Handler on_change — cada keystroke¶
def on_query_change(event: TextChangeEvent) -> None:
def mutate(st: SearchState) -> None:
st.query = event.value
st.committed = ""
st.recompute()
app.set_state(mutate)
Três coisas acontecem atomicamente:
st.queryrecebe o texto atual do campo.st.committedé apagado — o usuário voltou a digitar, então a seleção anterior não é mais válida.st.recompute()recalcula as sugestões para o novoquery.
Handler on_select — seleção de sugestão¶
def on_suggestion_select(event: SelectEvent) -> None:
def mutate(st: SearchState) -> None:
st.committed = event.value
st.query = event.value
st.suggestions = []
app.set_state(mutate)
Aqui o comportamento é oposto: a lista de sugestões é esvaziada (não há
mais nada para mostrar) e committed recebe o valor escolhido. query também
recebe o valor para que o campo de texto exiba o nome do país selecionado.
Dica
SelectEvent.value carrega exatamente o item da lista options que o
usuário clicou — sem necessidade de índice ou mapeamento manual.
5. Chips de categoria com closures¶
def _make_chip(cat: str) -> Widget:
def click() -> None:
on_category(cat)
return Chip(
key=f"cat-{cat}",
label=cat,
selected=(s.category == cat),
on_click=click,
)
category_chips: list[Widget] = [_make_chip(cat) for cat in _CATEGORIES]
A função _make_chip cria um chip por categoria. O ponto crítico é o uso de
uma função-fábrica em vez de um lambda direto no for. Se você escrevesse:
# ❌ Armadilha clássica de closure em loop
for cat in _CATEGORIES:
Chip(on_click=lambda: on_category(cat), ...)
todos os lambdas capturariam a mesma variável cat do loop — ao clicar,
todos disparariam com o valor final do loop ("Europe"). _make_chip resolve
isso porque cada chamada cria um novo escopo com seu próprio cat.
selected=(s.category == cat) renderiza o chip como ativo visualmente quando
ele corresponde à categoria atual do estado.
Aviso
Esse padrão — fábrica para capturar variável de loop — é necessário sempre
que você criar widgets com handlers dentro de um for. Lambda direto no
loop é uma armadilha de closure clássica em Python.
6. Área de resultado condicional¶
if s.committed:
result_children: list[Widget] = [
Text(
key="chosen-label",
content="Selected country:",
style=Style(font_size=13.0),
),
Row(
key="chosen-row",
style=Style(gap=8.0),
children=[
Text(
key="chosen-value",
content=s.committed,
style=Style(font_size=18.0),
),
Button(
key="clear-btn",
label="Clear",
on_click=on_clear,
style=Style(
padding=Edge.symmetric(vertical=4.0, horizontal=10.0),
radius=6.0,
),
),
],
),
]
else:
result_children = [
Text(
key="prompt",
content="Type a country name or pick one from the suggestions.",
style=Style(font_size=14.0),
),
]
A view constrói a lista de filhos do painel de resultado antes de
montar a árvore final. Quando há committed, exibe o nome em destaque e um
botão "Clear". Quando não há, exibe apenas uma instrução.
Montar result_children antes do return Column(...) mantém o código legível:
a árvore principal fica limpa, sem lógicas if/else aninhadas no meio dos
argumentos.
Dica
Esse padrão — pré-computar listas de filhos — é recomendado sempre que a árvore tiver ramificações condicionais. Evita ternários profundamente aninhados nos argumentos do widget pai.
7. O layout com Wrap¶
Wrap é um container que posiciona os filhos em linha e quebra para a
próxima linha automaticamente quando o espaço horizontal se esgota. Para três
chips curtos isso raramente importa, mas com muitas categorias (ou em telas
estreitas) o comportamento se torna essencial — diferente de Row, que
transbordaria o container.
8. Edge.symmetric para padding assimétrico¶
Edge.symmetric cria um Edge com top=bottom=vertical e
left=right=horizontal — um atalho conveniente para padding de botão
("mais largo do que alto"). Compare com Edge.all(n) (mesmo valor nos quatro
lados) e Edge(top, right, bottom, left) (controle total).
Rodando o app 🚀¶
Salve o arquivo em examples/search-autocomplete/app.py e escolha o modo:
O Pyodide carrega o Python completo no browser. Nenhum servidor, nenhum WebSocket — os handlers Python rodam localmente no tab.
Mesmo código, dois modos
Repare que o app.py não menciona wasm nem server em lugar algum.
A fronteira de transporte fica completamente dentro do tempestweb — você
só escolhe no momento de rodar.
Abra o browser em http://localhost:8000 e experimente:
- Clique em "Americas" — as sugestões mudam para países das Américas.
- Digite
"bra"— a lista se filtra paraBrazilimediatamente. - Clique em
Brazilna lista — o painel de resultado exibe o país selecionado. - Clique em "Clear" — o campo e o resultado voltam ao estado inicial.
Recapitulando¶
Neste exemplo você aprendeu:
- ✅
Autocomplete— campo com sugestões dinâmicas viaoptions; dois handlers tipados:on_change(TextChangeEvent) eon_select(SelectEvent). - ✅
Chipcomselected— pílula clicável com estado visual de ativo/inativo. - ✅
Wrap— container que quebra linha automaticamente, ideal para conjuntos de chips. - ✅ Estado derivado +
recompute()— centraliza o recálculo desuggestionsem um único método. - ✅ Fábrica de closures em loop —
_make_chip(cat)captura cada valor decatcorretamente; lambda direto no loop seria uma armadilha. - ✅ Pré-computar filhos condicionais — monta
result_childrenantes doreturnpara manter a árvore principal legível. - ✅
Edge.symmetric— atalho para padding assimétrico (botões, chips).
Próximos passos¶
- Leia o Tutorial do Counter se ainda não o fez — ele
explica
set_statee o ciclo de rebuild com mais profundidade. - Compare com o exemplo de Formulário de Login para ver como
on_changeé usado com múltiplos campos de texto. - Veja como o exemplo de Abas de Perfil usa
Chipem um contexto de navegação. - Explore outros exemplos na seção Exemplos para mais padrões de estado e composição de widgets.