Menu de Ações
Menu suspenso de ações ou de escolha única (com ✓) preso a qualquer gatilho: abre num portal sob o botão, sobe quando falta espaço, fecha ao clicar fora, rolar ou com Esc, e tem navegação por setas, Home/End e busca pela primeira letra.
Playground
Props
Geradas dos tipos TypeScript do componente; a descrição vem dos comentários /** */.
<MenuDeAcoes>
Menu suspenso de ações (ou de escolha única, com ✓) preso a qualquer gatilho. Abre num portal logo abaixo do gatilho, sobe quando falta espaço e não passa da borda da janela. Fecha ao escolher, ao clicar fora, ao rolar a página e com Esc (que não chega a um modal por trás). Teclado: setas, Home/End, Enter/Espaço e busca pela primeira letra.
| Prop | Tipo | Padrão | Descrição |
|---|---|---|---|
| itensobrigatória | ItemDoMenu[] | — | |
| gatilhoobrigatória | ReactNode | ((props: PropsDoGatilho, aberto: boolean) => ReactNode) | — | Texto/conteúdo do botão padrão, ou uma função que desenha o gatilho próprio espalhando as props recebidas num `<button>`: `(props, aberto) => <button {...props}>…</button>`. |
| modo | "acoes" | "escolha" | "acoes" | "escolha": as ações viram opções de escolha única (menuitemradio) e o foco abre na marcada. |
| alinhamento | "inicio" | "fim" | "inicio" | Alinha o painel pela borda esquerda ("inicio") ou direita ("fim") do gatilho. |
| larguraDoPainel | string | "w-60" | Classe de largura do painel (nunca fica mais estreito que o gatilho). |
| cabecalho | string | number | bigint | boolean | ReactElement<unknown, string | JSXElementConstructor<any>> | Iterable<ReactNode> | ReactPortal | Promise<AwaitedReactNode> | — | Bloco fixo no topo do painel, fora da lista (ex.: nome e e-mail da conta). |
| desativado | boolean | false | Desativa o botão padrão. |
Código
stories.tsx tem os exemplos usados no preview e no playground.
"use client";
import {
Fragment,
useCallback,
useEffect,
useId,
useLayoutEffect,
useRef,
useState,
type KeyboardEvent,
type ReactNode,
} from "react";
import { createPortal } from "react-dom";
/** Uma ação do menu. */
export type AcaoDoMenu = {
tipo?: "item";
rotulo: string;
aoEscolher: () => void;
/** Ícone à esquerda (SVG de 16 px, por exemplo). */
icone?: ReactNode;
/** Ação destrutiva (excluir, sair): texto na cor de erro. */
perigo?: boolean;
desativado?: boolean;
/** Atalho de teclado mostrado à direita, só como lembrete ("Ctrl+D"). */
atalho?: string;
/** Mostra ✓. No modo "acoes", vira caixa de marcar (menuitemcheckbox); no modo "escolha", a opção escolhida. */
marcado?: boolean;
};
/** Item do menu: uma ação, uma linha separadora ou o título de um grupo (vale até o próximo separador ou grupo). */
export type ItemDoMenu = AcaoDoMenu | { tipo: "separador" } | { tipo: "grupo"; titulo: string };
/** Props para espalhar no gatilho próprio (um `<button>`), incluindo a ref. */
export type PropsDoGatilho = {
ref: (elemento: HTMLElement | null) => void;
id: string;
type: "button";
"aria-haspopup": "menu";
"aria-expanded": boolean;
"aria-controls": string | undefined;
onClick: () => void;
onKeyDown: (e: KeyboardEvent<HTMLElement>) => void;
};
type Foco = "primeiro" | "ultimo" | "marcado";
type Secao = { titulo?: string; separada: boolean; acoes: AcaoDoMenu[] };
/** Junta os itens em seções: um separador ou um título de grupo começa uma nova. */
function emSecoes(itens: ItemDoMenu[]): Secao[] {
const secoes: Secao[] = [];
for (const item of itens) {
if (item.tipo === "separador") {
// A linha só aparece entre seções, nunca no topo
secoes.push({ separada: secoes.length > 0, acoes: [] });
} else if (item.tipo === "grupo") {
const atual = secoes[secoes.length - 1];
// Grupo logo depois de um separador aproveita a seção vazia que ele abriu
if (atual && atual.acoes.length === 0 && !atual.titulo) atual.titulo = item.titulo;
else secoes.push({ titulo: item.titulo, separada: false, acoes: [] });
} else {
if (secoes.length === 0) secoes.push({ separada: false, acoes: [] });
secoes[secoes.length - 1].acoes.push(item);
}
}
return secoes.filter((s) => s.acoes.length > 0 || s.titulo);
}
const semAcentos = (texto: string) =>
texto.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLocaleLowerCase("pt-BR");
/** Itens que recebem foco pelo teclado (os desativados ficam de fora). */
const itensFocaveis = (menu: HTMLElement | null) =>
Array.from(menu?.querySelectorAll<HTMLElement>("[data-item-do-menu]:not([aria-disabled='true'])") ?? []);
/** Distância (px) entre o gatilho e o painel, e margem mínima até a borda da janela. */
const DISTANCIA = 6;
const MARGEM = 8;
/** Desenha o gatilho de quem usa o menu, com as props de acessibilidade, a ref e os eventos. */
function GatilhoProprio({
desenhar,
props,
aberto,
}: {
desenhar: (props: PropsDoGatilho, aberto: boolean) => ReactNode;
props: PropsDoGatilho;
aberto: boolean;
}) {
return desenhar(props, aberto);
}
function Visto({ className = "" }: { className?: string }) {
return (
<svg aria-hidden="true" viewBox="0 0 24 24" className={`size-4 shrink-0 ${className}`} fill="none" stroke="currentColor" strokeWidth={2.4} strokeLinecap="round" strokeLinejoin="round">
<path d="m5 12.5 4.5 4.5L19 7.5" />
</svg>
);
}
/**
* Menu suspenso de ações (ou de escolha única, com ✓) preso a qualquer gatilho.
* Abre num portal logo abaixo do gatilho, sobe quando falta espaço e não passa da
* borda da janela. Fecha ao escolher, ao clicar fora, ao rolar a página e com Esc
* (que não chega a um modal por trás). Teclado: setas, Home/End, Enter/Espaço e
* busca pela primeira letra.
*/
export function MenuDeAcoes({
itens,
gatilho,
modo = "acoes",
alinhamento = "inicio",
larguraDoPainel = "w-60",
cabecalho,
desativado = false,
}: {
itens: ItemDoMenu[];
/**
* Texto/conteúdo do botão padrão, ou uma função que desenha o gatilho próprio
* espalhando as props recebidas num `<button>`: `(props, aberto) => <button {...props}>…</button>`.
*/
gatilho: ReactNode | ((props: PropsDoGatilho, aberto: boolean) => ReactNode);
/** "escolha": as ações viram opções de escolha única (menuitemradio) e o foco abre na marcada. */
modo?: "acoes" | "escolha";
/** Alinha o painel pela borda esquerda ("inicio") ou direita ("fim") do gatilho. */
alinhamento?: "inicio" | "fim";
/** Classe de largura do painel (nunca fica mais estreito que o gatilho). */
larguraDoPainel?: string;
/** Bloco fixo no topo do painel, fora da lista (ex.: nome e e-mail da conta). */
cabecalho?: ReactNode;
/** Desativa o botão padrão. */
desativado?: boolean;
}) {
const id = useId();
const idGatilho = `${id}-gatilho`;
const idMenu = `${id}-menu`;
// null = fechado; aberto guarda onde montar o portal e que item focar
const [aberto, setAberto] = useState<{ alvo: HTMLElement; foco: Foco } | null>(null);
const gatilhoEl = useRef<HTMLElement | null>(null);
const painel = useRef<HTMLDivElement>(null);
const menu = useRef<HTMLDivElement>(null);
const busca = useRef({ texto: "", quando: 0 });
const refDoGatilho = useCallback((elemento: HTMLElement | null) => {
gatilhoEl.current = elemento;
}, []);
function abrir(foco: Foco) {
const elemento = gatilhoEl.current;
if (!elemento) return;
// O portal vai para o ancestral com data-theme (onde moram as variáveis do tema) ou para o <body>
const tema = elemento.closest<HTMLElement>("[data-theme]");
setAberto({ alvo: tema && tema !== document.documentElement ? tema : document.body, foco });
}
function fecharEVoltar() {
setAberto(null);
gatilhoEl.current?.focus();
}
const posicionar = useCallback(() => {
const g = gatilhoEl.current;
const p = painel.current;
if (!g || !p) return;
const caixa = g.getBoundingClientRect();
p.style.maxHeight = "";
p.style.minWidth = `${Math.min(caixa.width, window.innerWidth - 2 * MARGEM)}px`;
const largura = p.offsetWidth;
const altura = p.offsetHeight;
const desejado = alinhamento === "fim" ? caixa.right - largura : caixa.left;
const left = Math.min(Math.max(desejado, MARGEM), window.innerWidth - largura - MARGEM);
const abaixo = window.innerHeight - caixa.bottom - DISTANCIA - MARGEM;
const acima = caixa.top - DISTANCIA - MARGEM;
// Sobe só quando não cabe embaixo e em cima há mais espaço
const sobe = altura > abaixo && acima > abaixo;
p.style.left = `${Math.max(left, MARGEM)}px`;
p.style.top = `${sobe ? Math.max(MARGEM, caixa.top - DISTANCIA - altura) : caixa.bottom + DISTANCIA}px`;
p.style.maxHeight = `${Math.max(sobe ? acima : abaixo, 120)}px`;
}, [alinhamento]);
// Antes de pintar: posiciona e leva o foco ao item certo
useLayoutEffect(() => {
if (!aberto) return;
posicionar();
const lista = itensFocaveis(menu.current);
const alvo =
aberto.foco === "ultimo"
? lista[lista.length - 1]
: aberto.foco === "marcado"
? (lista.find((el) => el.getAttribute("aria-checked") === "true") ?? lista[0])
: lista[0];
(alvo ?? menu.current)?.focus({ preventScroll: true });
}, [aberto, posicionar]);
useEffect(() => {
if (!aberto) return;
const fora = (e: PointerEvent) => {
const t = e.target as Node;
if (painel.current?.contains(t) || gatilhoEl.current?.contains(t)) return;
setAberto(null);
};
// Na captura da janela: o Esc fecha só o menu e não chega a um modal aberto por trás
const esc = (e: globalThis.KeyboardEvent) => {
if (e.key !== "Escape") return;
e.preventDefault();
e.stopPropagation();
setAberto(null);
gatilhoEl.current?.focus();
};
const rolagem = (e: Event) => {
if (e.target instanceof Node && painel.current?.contains(e.target)) return;
setAberto(null);
};
document.addEventListener("pointerdown", fora);
window.addEventListener("keydown", esc, true);
window.addEventListener("scroll", rolagem, true);
window.addEventListener("resize", posicionar);
return () => {
document.removeEventListener("pointerdown", fora);
window.removeEventListener("keydown", esc, true);
window.removeEventListener("scroll", rolagem, true);
window.removeEventListener("resize", posicionar);
};
}, [aberto, posicionar]);
function escolher(acao: AcaoDoMenu) {
if (acao.desativado) return;
// Foco de volta ao gatilho antes da ação: se ela abrir um modal, o modal fica com o foco
fecharEVoltar();
acao.aoEscolher();
}
function focar(elemento: HTMLElement | undefined) {
elemento?.focus({ preventScroll: true });
}
function aoTeclarNoMenu(e: KeyboardEvent<HTMLDivElement>) {
const lista = itensFocaveis(menu.current);
if (lista.length === 0) return;
const atual = lista.indexOf(document.activeElement as HTMLElement);
switch (e.key) {
case "ArrowDown":
e.preventDefault();
focar(lista[atual < 0 ? 0 : (atual + 1) % lista.length]);
return;
case "ArrowUp":
e.preventDefault();
focar(lista[atual <= 0 ? lista.length - 1 : atual - 1]);
return;
case "Home":
case "PageUp":
e.preventDefault();
focar(lista[0]);
return;
case "End":
case "PageDown":
e.preventDefault();
focar(lista[lista.length - 1]);
return;
case "Tab":
// Fecha e devolve o foco ao gatilho; o Tab segue dali para o próximo elemento da página
setAberto(null);
gatilhoEl.current?.focus();
return;
}
// Busca pela primeira letra (ou pelo começo do rótulo, digitando rápido)
if (e.key.length !== 1 || e.ctrlKey || e.metaKey || e.altKey) return;
const memoria = busca.current;
if (e.key === " " && e.timeStamp - memoria.quando > 500) return; // Espaço sozinho ativa o item
memoria.texto = e.timeStamp - memoria.quando < 500 ? memoria.texto + e.key : e.key;
memoria.quando = e.timeStamp;
const repetida = [...memoria.texto].every((c) => c === memoria.texto[0]);
const termo = semAcentos(repetida ? memoria.texto[0] : memoria.texto);
const inicio = repetida ? atual + 1 : Math.max(atual, 0);
for (let k = 0; k < lista.length; k++) {
const el = lista[(inicio + k) % lista.length];
if (semAcentos(el.dataset.rotulo ?? "").startsWith(termo)) {
e.preventDefault();
focar(el);
return;
}
}
}
const propsDoGatilho: PropsDoGatilho = {
ref: refDoGatilho,
id: idGatilho,
type: "button",
"aria-haspopup": "menu",
"aria-expanded": aberto !== null,
"aria-controls": aberto ? idMenu : undefined,
onClick: () => (aberto ? setAberto(null) : abrir(modo === "escolha" ? "marcado" : "primeiro")),
onKeyDown: (e) => {
if (aberto) return;
if (e.key === "ArrowDown") {
e.preventDefault();
abrir(modo === "escolha" ? "marcado" : "primeiro");
} else if (e.key === "ArrowUp") {
e.preventDefault();
abrir("ultimo");
}
},
};
const secoes = emSecoes(itens);
const desenharAcao = (acao: AcaoDoMenu, chave: string) => {
const papel = modo === "escolha" ? "menuitemradio" : acao.marcado !== undefined ? "menuitemcheckbox" : "menuitem";
const escolhida = modo === "escolha" && acao.marcado;
return (
<button
key={chave}
type="button"
role={papel}
aria-checked={papel === "menuitem" ? undefined : !!acao.marcado}
aria-disabled={acao.desativado || undefined}
tabIndex={-1}
data-item-do-menu=""
data-rotulo={acao.rotulo}
onClick={() => escolher(acao)}
onPointerMove={(e) => {
if (!acao.desativado && document.activeElement !== e.currentTarget) e.currentTarget.focus({ preventScroll: true });
}}
className={`flex min-h-10 w-full cursor-pointer items-center gap-3 px-3 py-2 text-left text-sm outline-accent focus:outline-none focus-visible:outline-2 focus-visible:-outline-offset-2 aria-disabled:cursor-not-allowed aria-disabled:opacity-50 ${
acao.perigo ? "text-critical focus:bg-critical/10" : `${escolhida ? "font-medium text-accent" : "text-ink"} focus:bg-surface`
}`}
>
{acao.icone && (
<span aria-hidden="true" className={`flex size-4 shrink-0 items-center justify-center ${acao.perigo ? "" : "text-muted"}`}>
{acao.icone}
</span>
)}
<span className="min-w-0 flex-1 truncate">{acao.rotulo}</span>
{acao.atalho && <kbd className="shrink-0 font-sans text-xs text-muted">{acao.atalho}</kbd>}
{acao.marcado && <Visto />}
</button>
);
};
return (
<>
{typeof gatilho === "function" ? (
<GatilhoProprio desenhar={gatilho} props={propsDoGatilho} aberto={aberto !== null} />
) : (
<button
{...propsDoGatilho}
disabled={desativado}
className="inline-flex min-h-10 cursor-pointer items-center gap-2 rounded-lg border border-rule bg-page px-3 text-sm font-medium text-ink outline-accent hover:bg-surface focus-visible:outline-2 focus-visible:outline-offset-2 disabled:cursor-not-allowed disabled:opacity-50 aria-expanded:bg-surface"
>
{gatilho}
<svg
aria-hidden="true"
viewBox="0 0 12 12"
className={`size-3 shrink-0 text-muted transition-transform ${aberto ? "rotate-180" : ""}`}
>
<path d="M2.5 4.5 6 8l3.5-3.5" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
)}
{aberto &&
createPortal(
<div
ref={painel}
className={`fixed z-50 flex max-w-[calc(100vw-1rem)] flex-col overflow-hidden rounded-xl border border-rule bg-page text-ink shadow-flutuante ${larguraDoPainel}`}
>
{cabecalho && <div className="shrink-0 border-b border-rule px-4 py-3 text-sm">{cabecalho}</div>}
<div
ref={menu}
id={idMenu}
role="menu"
aria-labelledby={idGatilho}
tabIndex={-1}
onKeyDown={aoTeclarNoMenu}
className="min-h-0 flex-1 overflow-y-auto overscroll-contain py-1 outline-none"
>
{secoes.map((secao, i) => (
<Fragment key={i}>
{secao.separada && <div role="separator" className="my-1 h-px bg-rule" />}
{secao.titulo ? (
<div role="group" aria-labelledby={`${id}-grupo-${i}`}>
<div id={`${id}-grupo-${i}`} className="px-3 pt-2 pb-1 text-xs font-semibold text-muted">
{secao.titulo}
</div>
{secao.acoes.map((acao, j) => desenharAcao(acao, `${i}-${j}`))}
</div>
) : (
secao.acoes.map((acao, j) => desenharAcao(acao, `${i}-${j}`))
)}
</Fragment>
))}
</div>
</div>,
aberto.alvo,
)}
</>
);
}
import { useState, type ReactNode } from "react";
import { defineStories } from "@/registry/stories";
import { MenuDeAcoes, type AcaoDoMenu, type ItemDoMenu } from "./MenuDeAcoes";
function Icone({ children }: { children: ReactNode }) {
return (
<svg
aria-hidden="true"
viewBox="0 0 24 24"
className="size-4"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
{children}
</svg>
);
}
const ICONES = {
editar: (
<Icone>
<path d="M4 20h4L18.5 9.5a2.1 2.1 0 0 0-4-4L4 16v4z" />
</Icone>
),
duplicar: (
<Icone>
<rect x="8" y="8" width="12" height="12" rx="2" />
<path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2" />
</Icone>
),
arquivar: (
<Icone>
<rect x="3" y="4" width="18" height="4" rx="1" />
<path d="M5 8v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8M10 12h4" />
</Icone>
),
excluir: (
<Icone>
<path d="M4 7h16M10 11v6M14 11v6M6 7l1 12a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-12M9 7V4h6v3" />
</Icone>
),
sair: (
<Icone>
<path d="M15 4h3a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-3M10 17l5-5-5-5M15 12H4" />
</Icone>
),
perfil: (
<Icone>
<circle cx="12" cy="8" r="4" />
<path d="M4 21a8 8 0 0 1 16 0" />
</Icone>
),
};
const BOTAO_ICONE =
"grid size-10 cursor-pointer place-items-center rounded-lg border border-rule bg-page text-ink-2 outline-accent hover:bg-surface hover:text-ink focus-visible:outline-2 focus-visible:outline-offset-2 aria-expanded:bg-surface aria-expanded:text-ink";
function acoesDeTarefa({
comIcones,
comAtalhos,
comGrupos,
aoEscolher,
}: {
comIcones: boolean;
comAtalhos: boolean;
comGrupos: boolean;
aoEscolher: (rotulo: string) => void;
}): ItemDoMenu[] {
const acao = (rotulo: string, icone: ReactNode, atalho: string, extra: Partial<AcaoDoMenu> = {}): AcaoDoMenu => ({
rotulo,
icone: comIcones ? icone : undefined,
atalho: comAtalhos ? atalho : undefined,
aoEscolher: () => aoEscolher(rotulo),
...extra,
});
return [
...(comGrupos ? [{ tipo: "grupo", titulo: "Tarefa" } as const] : []),
acao("Editar", ICONES.editar, "E"),
acao("Duplicar", ICONES.duplicar, "Ctrl+D"),
acao("Arquivar", ICONES.arquivar, "A", { desativado: true }),
{ tipo: "separador" },
...(comGrupos ? [{ tipo: "grupo", titulo: "Zona de perigo" } as const] : []),
acao("Excluir", ICONES.excluir, "Del", { perigo: true }),
];
}
function ExemploDeAcoes({
rotulo,
alinhamento,
comIcones,
comAtalhos,
comGrupos,
}: {
rotulo: string;
alinhamento: "inicio" | "fim";
comIcones: boolean;
comAtalhos: boolean;
comGrupos: boolean;
}) {
const [ultima, setUltima] = useState<string | null>(null);
return (
<div
className={`flex min-h-80 w-full max-w-md flex-col gap-4 in-data-miniatura:min-h-0 in-data-miniatura:items-center ${alinhamento === "fim" ? "items-end" : "items-start"}`}
>
<MenuDeAcoes
gatilho={rotulo}
alinhamento={alinhamento}
itens={acoesDeTarefa({ comIcones, comAtalhos, comGrupos, aoEscolher: setUltima })}
/>
<p className="text-sm text-muted" aria-live="polite">
{ultima ? `Escolhida: ${ultima}` : "Nenhuma ação escolhida ainda."}
</p>
</div>
);
}
const AGRUPAMENTOS = ["Sem agrupamento", "Responsável", "Projeto", "Prioridade"];
const ZOOMS = [70, 85, 100, 115, 130];
/** Escolha única com ✓, nos dois formatos do original: rótulo no botão e porcentagem. */
function ExemploDeEscolha() {
const [grupo, setGrupo] = useState(AGRUPAMENTOS[2]);
const [zoom, setZoom] = useState(100);
return (
<div className="flex min-h-80 flex-wrap items-start justify-center gap-3">
<MenuDeAcoes
modo="escolha"
gatilho={`Agrupar: ${grupo}`}
larguraDoPainel="w-56"
itens={[
{ tipo: "grupo", titulo: "Agrupar tarefas por" },
...AGRUPAMENTOS.map((g) => ({ rotulo: g, marcado: g === grupo, aoEscolher: () => setGrupo(g) })),
]}
/>
<MenuDeAcoes
modo="escolha"
alinhamento="fim"
larguraDoPainel="w-44"
gatilho={`Zoom ${zoom}%`}
itens={ZOOMS.map((z) => ({
rotulo: z === 100 ? "100% (padrão)" : `${z}%`,
marcado: z === zoom,
aoEscolher: () => setZoom(z),
}))}
/>
</div>
);
}
/** Gatilho próprio: botão só com ícone, e o menu da conta com cabeçalho. */
function ExemploDeGatilhos() {
const [mostrarConcluidas, setMostrarConcluidas] = useState(true);
return (
<div className="flex min-h-80 w-full max-w-md items-start justify-between gap-3">
<MenuDeAcoes
itens={[
{
rotulo: "Mostrar concluídas",
marcado: mostrarConcluidas,
aoEscolher: () => setMostrarConcluidas((v) => !v),
},
{ rotulo: "Exportar planilha", aoEscolher: () => {} },
]}
gatilho={(props) => (
<button {...props} aria-label="Mais opções" className={BOTAO_ICONE}>
<svg aria-hidden="true" viewBox="0 0 24 24" className="size-5" fill="currentColor">
<circle cx="5" cy="12" r="1.8" />
<circle cx="12" cy="12" r="1.8" />
<circle cx="19" cy="12" r="1.8" />
</svg>
</button>
)}
/>
<MenuDeAcoes
alinhamento="fim"
cabecalho={
<>
<p className="text-xs text-muted">Conectada como</p>
<p className="truncate font-semibold">Ana Souza</p>
<p className="truncate text-xs text-ink-2">Financeiro</p>
</>
}
itens={[
{ rotulo: "Meu perfil", icone: ICONES.perfil, aoEscolher: () => {} },
{ tipo: "separador" },
{ rotulo: "Sair", icone: ICONES.sair, perigo: true, aoEscolher: () => {} },
]}
gatilho={(props, aberto) => (
<button
{...props}
aria-label="Conta de Ana Souza"
className={`flex min-h-10 cursor-pointer items-center gap-2 rounded-lg px-2 text-sm font-semibold text-ink outline-accent hover:bg-surface focus-visible:outline-2 focus-visible:outline-offset-2 ${aberto ? "bg-surface" : ""}`}
>
<span className="hidden sm:inline">Ana Souza</span>
<span
aria-hidden="true"
className="grid size-8 place-items-center rounded-full bg-accent text-xs font-bold text-page"
>
AS
</span>
</button>
)}
/>
</div>
);
}
export default defineStories({
args: { rotulo: "Ações", alinhamento: "inicio", comIcones: true, comAtalhos: true, comGrupos: false },
controls: {
rotulo: { type: "text", label: "gatilho (texto do botão)" },
alinhamento: { type: "select", options: ["inicio", "fim"] },
comIcones: { type: "boolean", label: "ícones" },
comAtalhos: { type: "boolean", label: "atalhos" },
comGrupos: { type: "boolean", label: "títulos de grupo" },
},
render: (a) => <ExemploDeAcoes {...a} alinhamento={a.alinhamento as "inicio" | "fim"} />,
stories: [
{ name: "Ações" },
{
name: "Gatilhos",
render: () => (
<div className="flex flex-wrap items-center justify-center gap-3">
<MenuDeAcoes
gatilho="Ações"
itens={acoesDeTarefa({ comIcones: true, comAtalhos: true, comGrupos: false, aoEscolher: () => {} })}
/>
<MenuDeAcoes
modo="escolha"
gatilho="Agrupar: Projeto"
itens={AGRUPAMENTOS.map((g) => ({ rotulo: g, marcado: g === "Projeto", aoEscolher: () => {} }))}
/>
<MenuDeAcoes
alinhamento="fim"
itens={[
{ rotulo: "Mostrar concluídas", marcado: true, aoEscolher: () => {} },
{ rotulo: "Exportar planilha", aoEscolher: () => {} },
]}
gatilho={(props) => (
<button {...props} aria-label="Mais opções" className={BOTAO_ICONE}>
<svg aria-hidden="true" viewBox="0 0 24 24" className="size-5" fill="currentColor">
<circle cx="5" cy="12" r="1.8" />
<circle cx="12" cy="12" r="1.8" />
<circle cx="19" cy="12" r="1.8" />
</svg>
</button>
)}
/>
</div>
),
},
{ name: "Com grupos, alinhado à direita", args: { comGrupos: true, alinhamento: "fim" } },
{ name: "Escolha única", render: () => <ExemploDeEscolha /> },
{ name: "Gatilho próprio e cabeçalho", render: () => <ExemploDeGatilhos /> },
],
});
Para usar em outro projeto, copie também
Os componentes de que este depende e o tema. O CSS do tema é um arquivo só: importe depois do Tailwind.
Tema escolhido no cabeçalho (com a ponte para o Tailwind)
Ver o CSS do tema
/* Tema "Editorial", gerado no Component Vault.
Importe depois de @import "tailwindcss". Carregue as fontes "Archivo" e "Inter" no projeto. */
@theme inline {
--color-page: var(--page);
--color-surface: var(--surface);
--color-track: var(--track);
--color-grid: var(--grid);
--color-ink: var(--ink);
--color-ink-2: var(--ink-2);
--color-muted: var(--muted);
--color-rule: var(--rule);
--color-rule-strong: var(--rule-strong);
--color-axis: var(--axis);
--color-accent: var(--accent);
--color-success: var(--success);
--color-warning: var(--warning);
--color-critical: var(--critical);
--font-display: var(--fonte-titulo);
--shadow-flutuante: var(--sombra-flutuante);
}
:root {
color-scheme: light dark;
--page: light-dark(#ffffff, #0f0f0e);
--surface: light-dark(#f5f5f5, #1a1a19);
--track: light-dark(#f0f0f0, #262624);
--grid: light-dark(#ededed, #232321);
--ink: light-dark(#111111, #f0efec);
--ink-2: light-dark(#4a4a4a, #c3c2b7);
--muted: light-dark(#666666, #a3a29a);
--rule: light-dark(#e3e3e3, #2c2c2a);
--rule-strong: light-dark(#111111, #f0efec);
--axis: light-dark(#9a9a9a, #6b6a64);
--accent: light-dark(#1a56db, #7aa2f7);
--success: light-dark(#13865c, #3fc48f);
--warning: #fab219;
--critical: #d03b3b;
--series-1: light-dark(#2a78d6, #3987e5);
--series-2: light-dark(#eb6834, #d95926);
--series-3: light-dark(#1baf7a, #199e70);
--series-4: light-dark(#eda100, #c98500);
--series-5: light-dark(#e87ba4, #d55181);
--series-6: #008300;
--series-7: light-dark(#6250d6, #9085e9);
--series-8: light-dark(#e34948, #e66767);
--fonte-titulo: "Archivo", system-ui, -apple-system, "Segoe UI", sans-serif;
--fonte-texto: "Inter", system-ui, -apple-system, "Segoe UI", sans-serif;
--spacing: 0.25rem;
--text-xs: 0.8rem;
--text-sm: 0.8944rem;
--text-base: 1rem;
--text-lg: 1.118rem;
--text-xl: 1.25rem;
--text-2xl: 1.5625rem;
--text-3xl: 1.9531rem;
--text-4xl: 2.4414rem;
--text-5xl: 3.0518rem;
--text-6xl: 3.8147rem;
--text-7xl: 4.7684rem;
--radius-xs: 2px;
--radius-sm: 4px;
--radius-md: 6px;
--radius-lg: 8px;
--radius-xl: 12px;
--radius-2xl: 16px;
--radius-3xl: 24px;
--radius-4xl: 32px;
--sombra-flutuante: 0 8px 24px -6px rgb(0 0 0 / 0.14), 0 2px 6px -2px rgb(0 0 0 / 0.08);
color: var(--ink);
font-family: var(--fonte-texto);
}
Na vitrine, a ponte com o Tailwind fica em src/themes/tokens.css:
/* Ponte entre os tokens do tema e o Tailwind.
Os componentes da biblioteca usam classes comuns do Tailwind (p-5, text-sm,
rounded-xl...) e as cores semânticas abaixo (bg-page, text-ink...). Dentro de
[data-theme="vault"], o tema redefine as variáveis que essas classes leem:
--spacing, --text-*, --radius-* e as cores. Os valores vêm do tema escolhido
na vitrine (src/themes/presets.ts) e são gerados por themeCss(). */
@theme inline {
--color-page: var(--page);
--color-surface: var(--surface);
--color-ink: var(--ink);
--color-ink-2: var(--ink-2);
--color-muted: var(--muted);
--color-rule: var(--rule);
--color-rule-strong: var(--rule-strong);
--color-track: var(--track);
--color-grid: var(--grid);
--color-axis: var(--axis);
--color-accent: var(--accent);
--color-success: var(--success);
--color-warning: var(--warning);
--color-critical: var(--critical);
--font-display: var(--fonte-titulo);
--shadow-flutuante: var(--sombra-flutuante);
}