Ação
Componentes de ação são o ponto onde o usuário dispara algo: clicar, escolher numa lista, confirmar. Eles carregam intenção — um clique muda dados, navega, ou inicia um fluxo. Por isso a categoria reúne tanto o gatilho direto (Button) quanto os elementos que cercam uma ação: dica contextual (Tooltip), conjunto de ações secundárias (DropdownMenu), painel ancorado (Popover) e a salvaguarda antes de algo destrutivo (ConfirmDialog).
Use esta página quando precisar que o usuário faça algo. Para entrada de dados (texto, seleção, datas) veja inputs; para apresentar coleções, veja data.
Button
Seção buttons da gallery — rode localmente para interagir.
Quando usar: a ação primária ou secundária de qualquer tela — submeter um form, abrir um modal, navegar. É o gatilho de ação por padrão.
Botão primário com variants, sizes, estado de loading.
import { Button } from "tempest-react-sdk";
import { Plus, Trash } from "lucide-react";
<Button>Salvar</Button>;
<Button variant="danger" leftIcon={<Trash size={16} />}>
Excluir
</Button>;
<Button variant="outline" loading>
Carregando…
</Button>;
<Button variant="link" rightIcon={<ArrowRight size={14} />}>
Ver mais
</Button>;
<Button iconOnly aria-label="Adicionar">
<Plus size={16} />
</Button>;
<Button fullWidth pill>
CTA
</Button>;
| Prop | Tipo | Default |
|---|---|---|
variant |
"primary" \| "secondary" \| "success" \| "danger" \| "soft" \| "outline" \| "ghost" \| "link" |
"primary" |
size |
"xs" \| "sm" \| "md" \| "lg" \| "xl" |
"md" |
loading |
boolean |
false |
fullWidth |
boolean |
false |
iconOnly |
boolean (square, requer aria-label) |
false |
pill |
boolean (border-radius pílula) |
false |
leftIcon |
ReactNode |
— |
rightIcon |
ReactNode |
— |
href |
string — renderiza <a> em vez de <button> |
— |
iconOnly precisa de rótulo acessível
iconOnly remove o texto visível, então leitores de tela não têm o que anunciar. Sempre passe aria-label descrevendo a ação (aria-label="Excluir"). Sem isso o botão é um ícone mudo para tecnologia assistiva.
loading bloqueia duplo clique
loading desabilita o botão e seta aria-busy="true" — é o padrão para submits assíncronos. Ative-o assim que disparar a request para evitar requisições duplicadas por cliques repetidos.
O rótulo continua sendo o nome acessível durante o loading: ele some só da pintura (o spinner o substitui na tela, na mesma largura), então o leitor de tela anuncia "Salvar, botão, indisponível" — e não um "botão" sem nome. O spinner é decorativo (aria-hidden) e não entra no nome; o estado vem de aria-busy e do disabled. Vale para <button>, href e ButtonSlot.
Botão que navega: href
Um CTA que leva a algum lugar — "Fale conosco" no WhatsApp, mailto:, "Ver planos", "Baixar" — é um link, não um botão. Com onClick={() => location.assign(url)} ele perde o clique do meio, o Ctrl+clique, o "abrir em nova aba", a prévia da URL e o crawler; com <a><Button /></a> vira HTML inválido, com dois tab stops. Com href, o Button renderiza um <a> com a mesma aparência em todas as variants e sizes:
import { Button } from "tempest-react-sdk";
import { MessageCircle } from "lucide-react";
export function Ctas() {
return (
<>
<Button href="/planos" variant="outline">
Ver planos
</Button>
<Button
href="https://wa.me/5586999999999"
target="_blank"
leftIcon={<MessageCircle size={16} />}
>
Fale conosco
</Button>
</>
);
}
targeterelsão repassados.- O
refé tipado por modo:HTMLButtonElementsemhref,HTMLAnchorElementcom.typesó existe no modo<button>—<Button href="/x" type="submit">não compila.
target=_blank ganha noopener noreferrer sozinho
Sem noopener, a aba aberta recebe window.opener e pode redirecionar a sua. É regra mecânica com uma resposta certa, então mora no componente: os tokens que você passar em rel ficam, e os que faltam são acrescentados sem duplicar.
Link desabilitado
<a> não tem disabled. Com disabled ou loading, o link recebe aria-disabled="true", role="link" e tabIndex={-1}, perde o href e tem o clique e o clique do meio cancelados — não navega e é anunciado como indisponível.
Por que InstallButton não aceita href
O clique dele abre o prompt de instalação ou mostra a instrução — é ação, não navegação. Ele herda as props do modo <button>.
ButtonSlot
Quando usar: dar a aparência do
Buttona outro elemento — tipicamente oLinkdo react-router, para trocar de rota sem recarregar a página.
O ButtonSlot não renderiza elemento próprio: aplica as classes, as props e o ref do botão ao único filho (o padrão asChild/Slot). Ele aceita as mesmas props de aparência do Button (variant, size, loading, disabled, fullWidth, iconOnly, pill, leftIcon, rightIcon), e o Button nunca importa o router.
import { ButtonSlot } from "tempest-react-sdk";
import { Link, NavLink } from "react-router";
export function Menu() {
return (
<>
<ButtonSlot variant="soft">
<Link to="/conta">Minha conta</Link>
</ButtonSlot>
<ButtonSlot variant="ghost">
<NavLink to="/" className={({ isActive }) => (isActive ? "ativo" : "")}>
Início
</NavLink>
</ButtonSlot>
</>
);
}
Como as props se combinam:
- Classes são somadas. Um
classNameem função (o doNavLink) continua função. - As props do
ButtonSlotvencem as do filho, porque são elas que carregam a semântica de desabilitado. - Handlers rodam os dois: o do
ButtonSlotprimeiro; se ele chamarpreventDefault(), o do filho não roda. - Os dois
ref— o do filho e o doButtonSlot— recebem o mesmo nó. - Um filho com
target="_blank"também ganhanoopener noreferrer.
Desabilitado em volta de um Link
Com disabled ou loading, o filho recebe aria-disabled="true" e tabIndex={-1}, e o clique e o clique do meio são cancelados — o Link pula a navegação porque o evento chega prevenido. Um <a href> comum ainda perde o href; o href de um Link é calculado por ele e continua no DOM.
Por que um componente e não uma prop asChild
O merge de props e a composição de refs custam ~0,6 kB brotli. Uma prop asChild é um desvio em tempo de execução que nenhum bundler remove, então todo app que usa Button pagaria por ele. Como export separado, o ButtonSlot sai do bundle de quem não o importa: a fatia { Button } mede 1,21 kB com href, contra 1,71 kB com asChild dentro do Button.
FloatingActionButton
Seção material da gallery — rode localmente para interagir.
Quando usar: a ação primária e persistente de uma tela (criar, compor, adicionar) que deve ficar sempre acessível, flutuando sobre o conteúdo. Redondo quando só tem ícone, ou estendido (pílula) quando tem
label.
Por padrão fica fixo no canto inferior direito; passe position="none" para posicioná-lo inline (ex.: dentro de um NavigationRail). Espalha todos os props nativos de <button> (onClick, disabled, etc.).
import { FloatingActionButton } from "tempest-react-sdk";
import { Plus } from "lucide-react";
<FloatingActionButton icon={<Plus />} aria-label="Novo" position="none" onClick={create} />;
<FloatingActionButton icon={<Plus />} label="Novo pedido" onClick={create} />;
| Prop | Tipo | Default |
|---|---|---|
icon |
ReactNode |
— |
label |
ReactNode (presente → FAB estendido) |
— |
position |
"bottom-right" \| "bottom-left" \| "none" |
"bottom-right" |
size |
"sm" \| "md" \| "lg" |
"md" |
variant |
"primary" \| "surface" |
"primary" |
| ... | Todos os atributos de HTMLButtonElement |
— |
FAB só de ícone precisa de aria-label
Sem label visível, o FAB redondo não tem nome acessível. Sempre passe aria-label descrevendo a ação (aria-label="Novo"); quando há label, ele já serve de nome.
Tooltip
Seção navigation da gallery — rode localmente para interagir.
Quando usar: dar contexto extra a um controle cujo significado não é óbvio — tipicamente botões
iconOnly. Nunca para informação crítica.
Hover tooltip em portal: escapa de ancestral com overflow (uma célula de DataTable, por exemplo) e vira para o lado oposto na borda da tela. Aparece no hover e no foco por teclado.
import { Button, Tooltip } from "tempest-react-sdk";
import { Trash } from "lucide-react";
export function ExcluirComDica() {
return (
<Tooltip content="Excluir permanentemente" placement="bottom" openDelay={300}>
<Button variant="danger" iconOnly aria-label="Excluir">
<Trash size={16} />
</Button>
</Tooltip>
);
}
| Prop | Tipo | Default |
|---|---|---|
content |
ReactNode |
— |
placement |
"top" \| "right" \| "bottom" \| "left" |
"top" |
openDelay |
number (ms antes de aparecer) |
150 |
disabled |
boolean (desliga sem mexer no trigger) |
false |
portal |
boolean (renderiza em document.body) |
true |
Escape esconde a dica sem mover o ponteiro nem o foco, como pede o WCAG 2.2
SC 1.4.13. Dentro de um Modal, o primeiro Escape esconde a dica e o segundo
fecha o modal.
Não esconda informação essencial num tooltip
Usuários de touch não têm hover — eles nunca verão o conteúdo. Tooltip é reforço, não a única fonte de uma informação necessária para concluir a tarefa.
DropdownMenu
Seção overlays da gallery — rode localmente para interagir.
Quando usar: agrupar ações secundárias atrás de um único gatilho ("Mais ações", menu de perfil) quando elas não cabem na barra principal.
Menu suspenso de ações. Navegação por teclado (↑↓ Home End Esc). Cada entrada precisa de um id estável (usado como key do React).
import { Button, DropdownMenu } from "tempest-react-sdk";
export function MaisAcoes({
navigate,
logout,
}: {
navigate: (to: string) => void;
logout: () => void;
}) {
return (
<DropdownMenu
trigger={<Button variant="ghost">Mais ações</Button>}
items={[
{ type: "label", id: "h", label: "Conta" },
{
type: "item",
id: "edit",
label: "Editar perfil",
onSelect: () => navigate("/profile"),
},
{ type: "separator", id: "s1" },
{ type: "item", id: "logout", label: "Sair", onSelect: logout, danger: true },
]}
/>
);
}
| Entry type | Campos |
|---|---|
"item" |
id, label, icon?, onSelect, disabled?, danger? |
"checkbox" |
id, label, icon?, checked, onSelect, disabled? |
"label" |
id, label |
"separator" |
id |
Props do componente: trigger (ReactElement), items (DropdownMenuEntry[]), placement ("bottom-start" \| "bottom-end" \| "top-start" \| "top-end", default "bottom-start"), portal (boolean, default true — ver Numa linha de tabela).
Numa linha de tabela
O menu de ações por linha é o caso mais comum de painel administrativo, e é
exatamente onde um menu em fluxo quebra: o wrapper do DataTable rola na
horizontal (overflow-x: auto), e pela regra do CSS isso força overflow-y a
auto também. Por isso o menu é renderizado em portal por padrão, ancorado
no gatilho.
import { Button, DataTable, DropdownMenu } from "tempest-react-sdk";
interface Usuario {
id: string;
nome: string;
}
export function TabelaDeUsuarios({
usuarios,
abrir,
excluir,
}: {
usuarios: Usuario[];
abrir: (id: string) => void;
excluir: (id: string) => void;
}) {
return (
<DataTable<Usuario>
data={usuarios}
rowKey={(usuario) => usuario.id}
columns={[
{ key: "nome", header: "Nome" },
{
key: "id",
header: "",
render: (usuario) => (
<DropdownMenu
placement="bottom-end"
trigger={
<Button variant="ghost" iconOnly aria-label="Ações">
⋮
</Button>
}
items={[
{
type: "item",
id: "ver",
label: "Ver perfil",
onSelect: () => abrir(usuario.id),
},
{
type: "item",
id: "excluir",
label: "Excluir",
danger: true,
onSelect: () => excluir(usuario.id),
},
]}
/>
),
},
]}
/>
);
}
O que o portal resolve, medido no Chrome a 1440 px com o menu de três entradas na última linha:
| Em fluxo (antes da 0.67.0) | Em portal | |
|---|---|---|
| Entradas visíveis | 1 de 3 | 3 de 3 |
| Sem espaço embaixo | abria abaixo do fim da tela | vira para cima do gatilho |
| Tabela rola com o menu aberto | — | o menu acompanha o gatilho |
Dentro de um Modal |
— | pinta por cima do modal |
Por que o menu em portal fica acima do Modal
Em portal, o menu vive em document.body, ao lado do modal — já não herda o
empilhamento dele. Ele usa --tempest-z-popover (1150), acima de
--tempest-z-modal (1100). Com --tempest-z-dropdown (1000), medido, o menu
aberto dentro de um modal ficava atrás dele.
portal={false} para voltar ao menu em fluxo
Use quando o contêiner precisa ser dono do corte ou do empilhamento do menu
de propósito. O mesmo portal existe no Popover e no Tooltip.
Item que liga e desliga
type: "checkbox" renderiza role="menuitemcheckbox" com aria-checked — que é como um leitor de tela anuncia "marcado" em vez de deixar o estado invisível.
import { useState } from "react";
import { Button, DropdownMenu } from "tempest-react-sdk";
export function MenuDaChamada({ alternarTema }: { alternarTema: () => void }) {
const [silencioso, setSilencioso] = useState(false);
return (
<DropdownMenu
trigger={<Button variant="ghost">Mais opções</Button>}
items={[
{
type: "checkbox",
id: "quiet",
label: "Silenciar os sons da chamada",
checked: silencioso,
onSelect: () => setSilencioso((valor) => !valor),
},
{ type: "separator", id: "s" },
{ type: "item", id: "tema", label: "Alternar tema", onSelect: alternarTema },
]}
/>
);
}
Item comum fecha, checkbox não
Selecionar um "item" dispara onSelect e fecha o menu. Um "checkbox"
alterna e deixa o menu aberto, porque ajustar duas preferências seguidas é
o caso comum e fechar após a primeira transformaria a segunda numa segunda
viagem.
Teclado
role="menu" é uma promessa sobre o teclado, e o componente a cumpre — o padrão
é o APG Menu Button:
| Tecla | No gatilho | No menu aberto |
|---|---|---|
Enter / Space |
abre e foca o primeiro item | ativa o item focado |
↓ |
abre e foca o primeiro item | próximo, com wrap |
↑ |
abre e foca o último item | anterior, com wrap |
Home / End |
— | primeiro / último |
Esc |
— | fecha e devolve o foco ao gatilho |
Tab |
segue a página | fecha e segue a página a partir do gatilho |
Foco gerenciado — Tab não percorre o menu
As entradas têm tabIndex={-1} e só a ativa tem 0. Sem isso, Tab andaria
item a item e faria o papel que a seta deveria fazer — o que é pior que
inacessível, porque o widget parece funcionar e contradiz o que o
role="menu" anunciou. Entrada desabilitada, label e separator nunca são
parada.
Popover
Quando usar: um painel flutuante com conteúdo arbitrário (filtros, mini-form, preview) ancorado a um gatilho — quando você precisa de mais que uma lista de ações.
Painel flutuante genérico (anchor + outside-click + Esc dismiss). Funciona controlado (open + onOpenChange) ou não-controlado (defaultOpen).
import { useState } from "react";
import { Button, Checkbox, Popover, Stack } from "tempest-react-sdk";
export function Filtros() {
const [open, setOpen] = useState(false);
return (
<Popover
open={open}
onOpenChange={setOpen}
placement="bottom"
trigger={<Button>Filtros</Button>}
>
<Stack gap={3}>
<Checkbox label="Apenas ativos" />
<Checkbox label="Pago" />
<Button onClick={() => setOpen(false)}>Aplicar</Button>
</Stack>
</Popover>
);
}
| Prop | Tipo | Default |
|---|---|---|
trigger |
ReactElement (clonado com handlers) |
— |
open |
boolean |
— (controlled) |
onOpenChange |
(open: boolean) => void |
— |
defaultOpen |
boolean (uso não-controlado) |
false |
placement |
"top" \| "bottom" \| "left" \| "right" |
"bottom" |
closeOnEsc |
boolean |
true |
closeOnOutsideClick |
boolean |
true |
portal |
boolean (renderiza em document.body) |
true |
Vira na borda da tela
Em portal (o padrão), o painel vai para o lado oposto quando o placement pedido não cabe na viewport, e é mantido dentro dela com 8 px de margem. Rolar a página ou um contêiner com o painel aberto o leva junto com o gatilho.
Tab entra no painel, mesmo em portal
Em portal, o painel mora no fim do body, e o browser o alcançaria por último.
O Popover devolve a ordem de quando ele estava em fluxo: Tab a partir do
gatilho aberto entra no primeiro campo do painel, Tab no último campo segue
para o que vem depois do gatilho, e Shift+Tab faz o caminho inverso.
ConfirmDialog
Seção modal da gallery — rode localmente para interagir.
Quando usar: a última barreira antes de uma ação irreversível ou cara (excluir, sobrescrever, cancelar). Sempre com
variant="danger"quando destrutiva.
Prompt destrutivo pré-montado em cima do Modal (texto + 2 botões).
import { useState } from "react";
import { ConfirmDialog } from "tempest-react-sdk";
export function ExcluirUsuario({
user,
deleteUser,
}: {
user: { id: string; name: string };
deleteUser: (id: string) => Promise<void>;
}) {
const [open, setOpen] = useState(false);
const [deleting, setDeleting] = useState(false);
return (
<ConfirmDialog
open={open}
title="Excluir usuário"
description={`Esta ação é permanente. Excluir ${user.name}?`}
confirmLabel="Sim, excluir"
cancelLabel="Cancelar"
variant="danger"
loading={deleting}
onConfirm={async () => {
setDeleting(true);
await deleteUser(user.id);
setDeleting(false);
setOpen(false);
}}
onCancel={() => setOpen(false)}
/>
);
}
| Prop | Tipo | Default |
|---|---|---|
open |
boolean |
— |
title |
ReactNode |
— |
description |
ReactNode |
— |
confirmLabel |
string |
"Confirmar" |
cancelLabel |
string |
"Cancelar" |
variant |
"primary" \| "danger" |
"primary" |
loading |
boolean (mostra spinner + desabilita ambos os botões) |
false |
onConfirm |
() => void \| Promise<void> |
— |
onCancel |
() => void |
— |
Controle o loading durante a request
onConfirm aceita uma promise, mas o ConfirmDialog não gerencia o estado de loading sozinho — passe loading={deleting} controlado pelo seu estado para travar ambos os botões enquanto a ação assíncrona corre.
InstallButton
Seção pwa da gallery — rode localmente para interagir.
Botão de instalação do PWA, ligado a useInstallPrompt. Onde o navegador dispara beforeinstallprompt, o clique instala. Onde não dispara — iOS Safari e os forks Chromium de Android (Mi Browser, UC, Opera Mini, Huawei) — o clique revela a instrução daquela plataforma em vez de o botão sumir. Herda todas as props do Button.
import { InstallButton } from "tempest-react-sdk";
import { Download } from "lucide-react";
<InstallButton variant="primary" leftIcon={<Download size={18} />} />;
| Prop | Tipo | Default |
|---|---|---|
label |
ReactNode |
"Instalar app" |
hintLabel |
ReactNode |
"Como instalar" |
onResult |
(o: "accepted" \| "dismissed" \| "unsupported") => void |
— |
renderHint |
(input: InstallHintInput) => ReactNode |
defaultInstallHint |
manualFallbackDelayMs |
number |
3000 |
wrapperClassName |
string |
— |
| … | todas as props de Button (variant, size, leftIcon) |
— |
Até a v0.65.0 os dois sumiam justamente onde o usuário precisa deles
InstallButton e InstallBanner eram construídos sobre useBeforeInstallPrompt, então renderizavam null em todo navegador que nunca dispara o evento. iOS Safari é exatamente esse caso — e é onde o caminho de instalação está escondido atrás do menu Compartilhar, que ninguém acha sem ser avisado. O resultado prático era cada app manter uma cópia local destes dois componentes só para acrescentar as trilhas ios e manual.
null agora só sai quando não há nada a oferecer: app já instalado, rodando standalone, ou dentro do período de dispensa.
renderHint troca a cópia sem reescrever o componente
O default é PT-BR e nomeia as entradas reais de menu — no Android também oferece o link intent:// que reabre a página no Chrome, que é o único caminho que chega a uma instalação de verdade num fork sem entrada de instalar.
import { InstallButton, defaultInstallHint } from "tempest-react-sdk";
<InstallButton
renderHint={(input) =>
input.method === "ios" ? <MeuTutorialIOS /> : defaultInstallHint(input)
}
/>;
InstallHintInput traz method ("native" | "ios" | "manual" | "none") e openInChromeIntent.
InstallBanner
Banner inferior dispensável que convida a instalar o PWA, sobre o mesmo useInstallPrompt. Onde o navegador não pode ser promptado, o botão vira "Como instalar" e revela a instrução da plataforma dentro do próprio banner. storageKey lembra a dispensa entre recarregamentos.
import { InstallBanner } from "tempest-react-sdk";
export function Instalar() {
return (
<InstallBanner
title="Instale o app"
description="Acesso offline e atalho na tela inicial."
storageKey="meu-app:install-dismissed"
/>
);
}
| Prop | Tipo | Default |
|---|---|---|
title |
ReactNode |
"Instale o app" |
description |
ReactNode |
— |
installLabel |
string |
"Instalar" |
dismissLabel |
string |
"Dispensar" |
icon |
ReactNode |
— |
hintLabel |
string |
"Como instalar" |
storageKey |
string |
— (sessão) |
declineCooldownMs |
number |
— (dispensa permanente) |
renderHint |
(input: InstallHintInput) => ReactNode |
defaultInstallHint |
manualFallbackDelayMs |
number |
3000 |
onResult |
(o) => void |
— |
declineCooldownMs muda o que é gravado — e respeita o que já estava lá
Sem ele, a dispensa gravada em storageKey é permanente, que é o que o componente sempre fez (grava "1"). Com ele, grava um timestamp e o banner volta depois da janela.
Uma chave que já contém "1" continua valendo como dispensa permanente mesmo com declineCooldownMs configurado: ela foi escrita sob o contrato antigo, e ressuscitar um banner que o usuário desligou é pior do que mantê-lo desligado.
Resumo
| Componente | Use para | Gatilho |
|---|---|---|
Button |
Disparar a ação primária/secundária | clique |
FloatingActionButton |
Ação primária flutuante e persistente | clique |
InstallButton |
Instalar o PWA, ou ensinar como onde não há prompt | clique |
InstallBanner |
Convite dispensável pra instalar o PWA | clique |
Tooltip |
Contexto não-crítico num controle | hover/foco |
DropdownMenu |
Lista de ações secundárias (fecha ao escolher) | clique |
Popover |
Painel flutuante com conteúdo arbitrário | clique |
ConfirmDialog |
Confirmar ação destrutiva antes de executar | — |
Pontos-chave de acessibilidade:
- Ações destrutivas devem usar
variant="danger". Button.loadingé o padrão para submits async — bloqueia duplos cliques.- Tooltips não devem conter informação crítica (usuários de touch não veem hover).
iconOnlyexigearia-label.
Relacionados: overlay (ConfirmDialog é construído sobre Modal) · inputs (entrada de dados) · feedback (toasts/alerts após a ação).





