Ir para o conteúdo

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

Buttons na gallery

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>
    </>
  );
}
  • target e rel são repassados.
  • O ref é tipado por modo: HTMLButtonElement sem href, HTMLAnchorElement com. type só 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 Button a outro elemento — tipicamente o Link do 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 className em função (o do NavLink) continua função.
  • As props do ButtonSlot vencem as do filho, porque são elas que carregam a semântica de desabilitado.
  • Handlers rodam os dois: o do ButtonSlot primeiro; se ele chamar preventDefault(), o do filho não roda.
  • Os dois ref — o do filho e o do ButtonSlot — recebem o mesmo nó.
  • Um filho com target="_blank" também ganha noopener 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

Material (ListTile · FAB · Rail) na gallery

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

AppBar · Tabs · Tooltip · Drawer na gallery

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.

Popover · Dropdown · HoverCard na gallery

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

Modal & Toast na gallery

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

PWA: Install · Push na gallery

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).
  • iconOnly exige aria-label.

Relacionados: overlay (ConfirmDialog é construído sobre Modal) · inputs (entrada de dados) · feedback (toasts/alerts após a ação).