FAQ Accordion¶
🚀 O que você vai construir: uma página de Perguntas Frequentes com itens expansíveis (
Accordion), política de abertura única — abrir um item fecha o anterior — e um campo de busca que filtra as entradas em tempo real.
Por que esse exemplo importa?¶
Padrões de disclosure (revelar/ocultar conteúdo sob demanda) aparecem em toda aplicação real: FAQs, seções de ajuda, sumários de pedido, painéis de configuração.
O Accordion encapsula esse comportamento de forma declarativa: você diz se está aberto ou fechado e fornece um handler de toggle — o framework cuida do DOM.
Neste tutorial você vai aprender a:
- Usar
Accordionpara revelar e ocultar conteúdo com um clique; - Implementar a política single-open (apenas um item aberto por vez);
- Filtrar uma lista de widgets em tempo real com
Input; - Compor layouts limpos com
Card,Divider,Row,ColumneText.
Nota
Este exemplo roda sem nenhuma alteração nos dois modos — WASM (Pyodide no
browser) e Servidor (FastAPI + WebSocket). A mesma view() Python serve os dois.
Pré-requisitos¶
Instale o tempestweb e confirme que o CLI está disponível:
Estrutura do projeto¶
Crie a pasta e o arquivo:
Passo 1 — Importações e dados do FAQ¶
Comece com todas as importações necessárias e defina a lista estática de pares pergunta/resposta.
from __future__ import annotations
from dataclasses import dataclass
from tempestweb._core import App, Style, Widget
from tempestweb._core.components import Accordion, Card, Divider
from tempestweb._core.style import Edge, FontWeight
from tempestweb._core.widgets import Column, Input, Row, Text
from tempestweb._core.widgets.events import TextChangeEvent
_FAQ_ENTRIES: list[tuple[str, str]] = [
(
"What is tempestweb?",
"tempestweb is a framework that lets you build interactive web applications "
"entirely in typed Python. You declare a widget tree once and the framework "
"renders it in the browser (Mode A, Pyodide/WASM) or via a FastAPI "
"WebSocket server (Mode B) — your view function never needs to know "
"which mode is active.",
),
(
"Do I need to write any JavaScript?",
"No. The client-side runtime is a small, zero-dependency JavaScript module "
"that ships with the framework. Application logic lives exclusively in Python; "
"the JS layer only handles DOM patching and transport I/O.",
),
(
"What is the difference between Mode A and Mode B?",
"Mode A runs Python directly in the browser via Pyodide (WebAssembly). "
"There is no server round-trip for state changes — everything happens "
"client-side. Mode B runs Python on a FastAPI server; the browser sends "
"events over a WebSocket and receives patch sequences back. Mode A is "
"simpler to deploy (static hosting); Mode B gives full server-side access "
"to databases and services.",
),
(
"How do I manage state?",
"Define a plain Python ``@dataclass`` as your state and pass a fresh "
"instance to ``make_state()``. Inside your ``view`` function you call "
"``app.set_state(lambda s: ...)`` to mutate state and trigger a "
"reconciled rebuild. The framework diffs the old and new widget trees "
"and sends only the minimal patch sequence to the renderer.",
),
(
"Can I use third-party Python packages?",
"In Mode B (server) you can use any Python package as normal. In Mode A "
"(Pyodide) you are limited to packages that Pyodide ships or that are "
"pure-Python wheels, because the package must run inside the browser's "
"WebAssembly sandbox.",
),
(
"Is TypeScript or a build step required?",
"No TypeScript and no build step. The client runtime is plain ES-module "
"JavaScript. You open the app with a single ``<script type='module'>`` "
"tag — no bundler, no transpiler, no node_modules.",
),
(
"How does styling work?",
"Styles are inline, typed Python objects (``Style``, ``Edge``, ``Color``, "
"etc.). There is no CSS cascade. Each widget carries its own ``Style`` "
"instance; the renderer serialises it to inline DOM styles. This keeps "
"styles predictable, refactorable with mypy, and free of selector "
"specificity surprises.",
),
]
Dica
_FAQ_ENTRIES é uma constante de módulo — uma lista simples de tuplas
(pergunta, resposta). Nada de banco de dados, nada de ORM: o foco deste
exemplo é a UI.
O que acabou de acontecer:
- As importações trazem exatamente o que o app usa — sem extras desnecessários.
_FAQ_ENTRIESenumera os pares de conteúdo; o estado vai rastrear qual está aberto e qual texto está sendo buscado.
Passo 2 — Definir o estado¶
O estado deste app é mínimo: qual item está aberto (ou -1 para nenhum) e o texto digitado no campo de busca.
@dataclass
class FaqState:
"""State for the FAQ accordion app.
Attributes:
open_index: The index of the currently expanded FAQ entry, or ``-1``
when all items are collapsed.
query: The current value of the search/filter field.
"""
open_index: int = -1
query: str = ""
def make_state() -> FaqState:
"""Build the initial FAQ state with the first entry pre-expanded.
Returns:
A fresh :class:`FaqState` with the first accordion open so the page
renders a non-empty visible body on first mount.
"""
return FaqState(open_index=0)
Info
open_index = 0 em make_state() faz com que a primeira pergunta já apareça
expandida ao carregar a página — uma experiência melhor do que uma lista
completamente recolhida.
O que está acontecendo:
| Campo | Tipo | Papel |
|---|---|---|
open_index |
int |
Índice do Accordion aberto; -1 = todos fechados |
query |
str |
Texto digitado no campo de busca em tempo real |
A política de single-open é implementada inteiramente na lógica do view, não no estado: open_index guarda no máximo um índice por vez.
Passo 3 — Handlers de evento¶
Antes de montar os widgets, defina as duas funções que respondem a interações do usuário. Elas vivem dentro de view para ter acesso direto a app.
def view(app: App[FaqState]) -> Widget:
"""Render the FAQ accordion page from the current state.
The page has three regions:
1. A title heading and a live-search ``Input`` that filters entries.
2. A ``Column`` of ``Accordion`` items — one per matching FAQ entry.
A single-open policy means toggling an entry collapses whatever was
previously open.
3. A muted footer showing how many entries match the current query.
Args:
app: The application handle exposing ``state`` and ``set_state``.
Returns:
The widget tree for the current state.
"""
def on_search(event: TextChangeEvent) -> None:
"""Update the query and reset the open item when the filter changes.
Args:
event: The text-change event carrying the new input value.
"""
def mutate(s: FaqState) -> None:
s.query = event.value
s.open_index = -1
app.set_state(mutate)
def toggle(entry_index: int) -> None:
"""Expand the clicked entry or collapse it if it is already open.
Args:
entry_index: The index into :data:`_FAQ_ENTRIES` that was toggled.
"""
def mutate(s: FaqState) -> None:
s.open_index = -1 if s.open_index == entry_index else entry_index
app.set_state(mutate)
Dica
Perceba que on_search reseta open_index para -1 cada vez que o usuário
digita algo. Isso evita que um item aberto em uma busca anterior continue visível
de forma confusa ao filtrar de novo.
Destaques:
app.set_state(mutate)recebe uma função que modifica o estado no lugar. O framework roda a função, gera o diff da árvore e envia apenas os patches necessários para o DOM — nunca um re-render completo.- A política single-open no handler
toggle:s.open_index = -1 if s.open_index == entry_index else entry_index. Se o item clicado já estava aberto, fecha; caso contrário, abre o novo (implicitamente fechando qualquer outro, porqueopen_indexé um inteiro único).
Passo 4 — Filtrar as entradas visíveis¶
Com o estado e os handlers prontos, filtre _FAQ_ENTRIES com base na query atual.
query_lower = app.state.query.strip().lower()
visible: list[tuple[int, str, str]] = [
(idx, question, answer)
for idx, (question, answer) in enumerate(_FAQ_ENTRIES)
if not query_lower
or query_lower in question.lower()
or query_lower in answer.lower()
]
Info
A busca verifica tanto a pergunta quanto a resposta — assim o usuário pode digitar uma palavra-chave do conteúdo e encontrar a entrada mesmo sem saber o título exato.
O que está acontecendo:
enumerate(_FAQ_ENTRIES)preserva o índice original (idx) — importante porquetoggle(idx)precisa do índice global, não da posição dentro da lista filtrada.if not query_lower— quando o campo está vazio, todas as entradas são visíveis.- O resultado é
visible: list[tuple[int, str, str]]— tuplas de(índice_global, pergunta, resposta).
Passo 5 — Construir os itens do Accordion¶
Itere sobre as entradas visíveis e crie um Accordion para cada uma, com a closure de toggle corretamente capturada.
accordion_items: list[Widget] = []
for entry_index, question, answer in visible:
is_open = app.state.open_index == entry_index
def make_toggle(i: int = entry_index) -> None:
"""Closure toggling entry ``i``.
Args:
i: The FAQ entry index to toggle (default-bound at creation).
"""
toggle(i)
accordion_items.append(
Accordion(
key=f"faq-{entry_index}",
title=question,
open=is_open,
on_toggle=make_toggle,
children=[
Text(
content=answer,
key=f"answer-{entry_index}",
style=Style(font_size=15.0, line_height=1.6),
)
],
)
)
Aviso
Repare no padrão def make_toggle(i: int = entry_index). Em Python, closures
em loops capturam a variável, não o valor no momento da iteração. Ao usar
i = entry_index como argumento padrão, o valor é capturado no instante da
criação da função — cada Accordion recebe o toggle certo.
Destaques do Accordion:
| Prop | Tipo | O que faz |
|---|---|---|
key |
str |
Identificador único para o reconciliador |
title |
str |
Texto do cabeçalho clicável |
open |
bool |
Controla se o corpo está expandido (controlled component) |
on_toggle |
callable |
Chamado quando o usuário clica no cabeçalho |
children |
list[Widget] |
Conteúdo exibido quando open=True |
Passo 6 — Rodapé com contador e estado vazio¶
Calcule o texto do contador e prepare a mensagem de estado vazio.
total = len(_FAQ_ENTRIES)
shown = len(visible)
if query_lower:
stripped = app.state.query.strip()
counter_text = f'{shown} of {total} questions match "{stripped}"'
else:
counter_text = f"{total} questions"
Quando nenhuma entrada corresponde à busca, a lista de accordion_items fica vazia. Isso será tratado na montagem da árvore final com uma mensagem de feedback.
Passo 7 — Montar a árvore completa¶
Agora reúna tudo na árvore de widgets retornada por view.
return Column(
key="faq-root",
style=Style(
gap=0.0,
padding=Edge.symmetric(vertical=24.0, horizontal=20.0),
),
children=[
# Page heading
Text(
content="Frequently Asked Questions",
key="heading",
style=Style(font_size=26.0, font_weight=FontWeight.BOLD),
),
Text(
content="Browse the most common questions or search below.",
key="subtitle",
style=Style(font_size=14.0, margin=Edge(top=6.0, bottom=20.0)),
),
# Search bar
Card(
key="search-card",
children=[
Row(
key="search-row",
style=Style(gap=8.0),
children=[
Text(content="Search:", key="search-label"),
Input(
key="search-input",
value=app.state.query,
placeholder="Filter questions…",
on_change=on_search,
),
],
)
],
),
# Accordion list (or empty-state message)
Column(
key="accordion-list",
style=Style(gap=8.0, margin=Edge(top=16.0)),
children=(
accordion_items
if accordion_items
else [
Text(
content="No questions match your search.",
key="empty-msg",
style=Style(font_size=14.0),
)
]
),
),
# Divider + footer
Divider(
key="footer-divider",
style=Style(margin=Edge(top=24.0, bottom=8.0)),
),
Text(
content=counter_text,
key="footer-counter",
style=Style(font_size=12.0),
),
],
)
O que está acontecendo:
- O
Columnraiz usaEdge.symmetric(vertical=24.0, horizontal=20.0)para um espaçamento confortável sem precisar definir cada lado manualmente. - O
Inputé controlado:value=app.state.querygarante que o campo sempre reflita o estado — nunca há dessincronia entre o que está na tela e o que está no estado. - A expressão ternária
accordion_items if accordion_items else [Text(...)]entrega o estado vazio de forma declarativa: nenhumifextra, nenhuma lógica de renderização condicional fora da árvore. Dividercomstyle=Style(margin=Edge(top=24.0, bottom=8.0))separa visualmente o rodapé do conteúdo principal.
Dica
Edge.symmetric(vertical=v, horizontal=h) é um atalho de Style para definir
top=v, bottom=v, left=h, right=h de uma vez. Veja outros atalhos como
Edge.all(n) e Edge.only(top=n) no exemplo
Perfil com Abas.
Passo 8 — O arquivo completo¶
Aqui está o app.py completo, pronto para copiar e colar:
"""FAQ Accordion — demonstrates the Disclosure pattern with ``Accordion``.
A realistic FAQ page that manages a list of question/answer pairs and tracks
which entry (if any) is currently expanded. The app enforces a single-open
policy: opening one item automatically collapses the previously open one,
keeping the page compact and focused. A search field filters the visible
entries in real time so users can quickly jump to the answer they need.
Like every tempestweb example, this exact ``view`` runs unchanged in both
execution modes::
tempestweb dev --mode wasm # Python in the browser (Pyodide)
tempestweb dev --mode server # Python on the server (FastAPI + WebSocket)
The application never names a transport — that is the whole point.
"""
from __future__ import annotations
from dataclasses import dataclass
from tempestweb._core import App, Style, Widget
from tempestweb._core.components import Accordion, Card, Divider
from tempestweb._core.style import Edge, FontWeight
from tempestweb._core.widgets import Column, Input, Row, Text
from tempestweb._core.widgets.events import TextChangeEvent
_FAQ_ENTRIES: list[tuple[str, str]] = [
(
"What is tempestweb?",
"tempestweb is a framework that lets you build interactive web applications "
"entirely in typed Python. You declare a widget tree once and the framework "
"renders it in the browser (Mode A, Pyodide/WASM) or via a FastAPI "
"WebSocket server (Mode B) — your view function never needs to know "
"which mode is active.",
),
(
"Do I need to write any JavaScript?",
"No. The client-side runtime is a small, zero-dependency JavaScript module "
"that ships with the framework. Application logic lives exclusively in Python; "
"the JS layer only handles DOM patching and transport I/O.",
),
(
"What is the difference between Mode A and Mode B?",
"Mode A runs Python directly in the browser via Pyodide (WebAssembly). "
"There is no server round-trip for state changes — everything happens "
"client-side. Mode B runs Python on a FastAPI server; the browser sends "
"events over a WebSocket and receives patch sequences back. Mode A is "
"simpler to deploy (static hosting); Mode B gives full server-side access "
"to databases and services.",
),
(
"How do I manage state?",
"Define a plain Python ``@dataclass`` as your state and pass a fresh "
"instance to ``make_state()``. Inside your ``view`` function you call "
"``app.set_state(lambda s: ...)`` to mutate state and trigger a "
"reconciled rebuild. The framework diffs the old and new widget trees "
"and sends only the minimal patch sequence to the renderer.",
),
(
"Can I use third-party Python packages?",
"In Mode B (server) you can use any Python package as normal. In Mode A "
"(Pyodide) you are limited to packages that Pyodide ships or that are "
"pure-Python wheels, because the package must run inside the browser's "
"WebAssembly sandbox.",
),
(
"Is TypeScript or a build step required?",
"No TypeScript and no build step. The client runtime is plain ES-module "
"JavaScript. You open the app with a single ``<script type='module'>`` "
"tag — no bundler, no transpiler, no node_modules.",
),
(
"How does styling work?",
"Styles are inline, typed Python objects (``Style``, ``Edge``, ``Color``, "
"etc.). There is no CSS cascade. Each widget carries its own ``Style`` "
"instance; the renderer serialises it to inline DOM styles. This keeps "
"styles predictable, refactorable with mypy, and free of selector "
"specificity surprises.",
),
]
@dataclass
class FaqState:
"""State for the FAQ accordion app.
Attributes:
open_index: The index of the currently expanded FAQ entry, or ``-1``
when all items are collapsed.
query: The current value of the search/filter field.
"""
open_index: int = -1
query: str = ""
def make_state() -> FaqState:
"""Build the initial FAQ state with the first entry pre-expanded.
Returns:
A fresh :class:`FaqState` with the first accordion open so the page
renders a non-empty visible body on first mount.
"""
return FaqState(open_index=0)
def view(app: App[FaqState]) -> Widget:
"""Render the FAQ accordion page from the current state.
The page has three regions:
1. A title heading and a live-search ``Input`` that filters entries.
2. A ``Column`` of ``Accordion`` items — one per matching FAQ entry.
A single-open policy means toggling an entry collapses whatever was
previously open.
3. A muted footer showing how many entries match the current query.
Args:
app: The application handle exposing ``state`` and ``set_state``.
Returns:
The widget tree for the current state.
"""
def on_search(event: TextChangeEvent) -> None:
"""Update the query and reset the open item when the filter changes.
Args:
event: The text-change event carrying the new input value.
"""
def mutate(s: FaqState) -> None:
s.query = event.value
s.open_index = -1
app.set_state(mutate)
def toggle(entry_index: int) -> None:
"""Expand the clicked entry or collapse it if it is already open.
Args:
entry_index: The index into :data:`_FAQ_ENTRIES` that was toggled.
"""
def mutate(s: FaqState) -> None:
s.open_index = -1 if s.open_index == entry_index else entry_index
app.set_state(mutate)
query_lower = app.state.query.strip().lower()
visible: list[tuple[int, str, str]] = [
(idx, question, answer)
for idx, (question, answer) in enumerate(_FAQ_ENTRIES)
if not query_lower
or query_lower in question.lower()
or query_lower in answer.lower()
]
accordion_items: list[Widget] = []
for entry_index, question, answer in visible:
is_open = app.state.open_index == entry_index
def make_toggle(i: int = entry_index) -> None:
"""Closure toggling entry ``i``.
Args:
i: The FAQ entry index to toggle (default-bound at creation).
"""
toggle(i)
accordion_items.append(
Accordion(
key=f"faq-{entry_index}",
title=question,
open=is_open,
on_toggle=make_toggle,
children=[
Text(
content=answer,
key=f"answer-{entry_index}",
style=Style(font_size=15.0, line_height=1.6),
)
],
)
)
total = len(_FAQ_ENTRIES)
shown = len(visible)
if query_lower:
stripped = app.state.query.strip()
counter_text = f'{shown} of {total} questions match "{stripped}"'
else:
counter_text = f"{total} questions"
return Column(
key="faq-root",
style=Style(
gap=0.0,
padding=Edge.symmetric(vertical=24.0, horizontal=20.0),
),
children=[
Text(
content="Frequently Asked Questions",
key="heading",
style=Style(font_size=26.0, font_weight=FontWeight.BOLD),
),
Text(
content="Browse the most common questions or search below.",
key="subtitle",
style=Style(font_size=14.0, margin=Edge(top=6.0, bottom=20.0)),
),
Card(
key="search-card",
children=[
Row(
key="search-row",
style=Style(gap=8.0),
children=[
Text(content="Search:", key="search-label"),
Input(
key="search-input",
value=app.state.query,
placeholder="Filter questions…",
on_change=on_search,
),
],
)
],
),
Column(
key="accordion-list",
style=Style(gap=8.0, margin=Edge(top=16.0)),
children=(
accordion_items
if accordion_items
else [
Text(
content="No questions match your search.",
key="empty-msg",
style=Style(font_size=14.0),
)
]
),
),
Divider(
key="footer-divider",
style=Style(margin=Edge(top=24.0, bottom=8.0)),
),
Text(
content=counter_text,
key="footer-counter",
style=Style(font_size=12.0),
),
],
)
Passo 9 — Executar o app¶
Execute no Modo A (Python no browser via Pyodide/WASM):
Execute no Modo B (Python no servidor via FastAPI + WebSocket):
Abra http://localhost:8000 no browser. Você deve ver:
- ✅ Título "Frequently Asked Questions" em destaque;
- ✅ Subtítulo e campo de busca dentro de um
Card; - ✅ Primeira pergunta já expandida ao carregar (graças a
make_state(open_index=0)); - ✅ Clicar em qualquer pergunta a expande e fecha a anteriormente aberta;
- ✅ Clicar em uma pergunta já aberta a recolhe;
- ✅ Digitar no campo de busca filtra os itens em tempo real;
- ✅ Quando nenhuma entrada corresponde, a mensagem "No questions match your search." aparece;
- ✅ O rodapé mostra o total de perguntas ou quantas correspondem à busca atual.
Verificação completa
Para garantir que o código passa em todas as checagens de qualidade:
ruff check examples/faq-accordion/app.py
ruff format --check examples/faq-accordion/app.py
mypy examples/faq-accordion/app.py
Todos os três devem sair com código 0.
Recapitulando¶
Neste tutorial você construiu uma página de FAQ completa com filtro ao vivo e aprendeu:
- 💡
Accordioné um controlled component:open=booleon_toggle=callablesão tudo o que você precisa. O estado fica no seu@dataclass, não dentro do widget. - 💡 A política single-open cabe em uma linha:
s.open_index = -1 if s.open_index == entry_index else entry_index. Nenhuma lógica especial de framework necessária. - 💡 Closures em loops exigem o padrão
def f(i: int = entry_index)para capturar o valor correto em cada iteração — uma pegadinha clássica do Python. - 💡
Inputcontrolado (value=app.state.query) garante que o campo nunca fique fora de sincronia com o estado — essencial para filtros ao vivo. - 💡
Card+Row+Dividercompõem um layout limpo sem nenhuma linha de CSS manual. - 💡 O mesmo
app.pyroda nos dois modos — WASM e Servidor — sem nenhuma alteração.
Próximos passos¶
- Veja o tutorial central para entender o ciclo de vida completo do tempestweb.
- Explore o padrão de abas no exemplo Perfil com Abas.
- Adicione animações de abertura/fechamento ao
Accordioncom oAnimatedSwitcher(em breve).