extraído de climatempo
Menu Suspenso
Botão em pílula (rótulo + resumo da escolha) que abre um painel suspenso. Fecha com Esc ou clique fora e devolve o foco ao botão. É a base dos seletores.
Variações
Playground
Props
Geradas dos tipos TypeScript do componente; a descrição vem dos comentários /** */.
<MenuSuspenso>
Botão em pílula (rótulo + resumo da escolha) que abre um painel suspenso. É a base dos seletores da barra e dos gráficos: fecha ao clicar fora ou com Esc (devolvendo o foco ao botão). O painel recebe `fechar`.
| Prop | Tipo | Padrão | Descrição |
|---|---|---|---|
| rotuloobrigatória | string | — | |
| resumoobrigatória | string | — | |
| larguraDoPainel | string | "w-72" | Classe de largura do painel. |
| alinhamento | "inicio" | "fim" | "fim" | Lado do botão em que o painel se alinha: "fim" (direita) ou "inicio" (esquerda, bom em barras laterais). |
| bloco | boolean | false | Botão com a largura toda do contêiner, em qualquer tela. |
| childrenobrigatória | (fechar: () => void) => ReactNode | — |
Código
stories.tsx tem os exemplos usados no preview e no playground.
menu-suspenso/MenuSuspenso.tsx
"use client";
import { useEffect, useId, useRef, useState, type ReactNode } from "react";
/**
* Botão em pílula (rótulo + resumo da escolha) que abre um painel suspenso. É a base dos seletores da barra
* e dos gráficos: fecha ao clicar fora ou com Esc (devolvendo o foco ao botão). O painel recebe `fechar`.
*/
export function MenuSuspenso({
rotulo,
resumo,
larguraDoPainel = "w-72",
alinhamento = "fim",
bloco = false,
children,
}: {
rotulo: string;
resumo: string;
/** Classe de largura do painel. */
larguraDoPainel?: string;
/** Lado do botão em que o painel se alinha: "fim" (direita) ou "inicio" (esquerda, bom em barras laterais). */
alinhamento?: "inicio" | "fim";
/** Botão com a largura toda do contêiner, em qualquer tela. */
bloco?: boolean;
children: (fechar: () => void) => ReactNode;
}) {
const [aberto, setAberto] = useState(false);
const raiz = useRef<HTMLDivElement>(null);
const botao = useRef<HTMLButtonElement>(null);
const idDoPainel = useId();
const jaAbriu = useRef(false);
// Ao fechar, se o foco ficou sem lugar (o item clicado sumiu) ou ainda está no menu, volta para o botão.
useEffect(() => {
if (aberto) {
jaAbriu.current = true;
return;
}
if (!jaAbriu.current) return;
const foco = document.activeElement;
if (!foco || foco === document.body || raiz.current?.contains(foco)) botao.current?.focus();
}, [aberto]);
useEffect(() => {
if (!aberto) return;
const fora = (e: PointerEvent) => {
if (!raiz.current?.contains(e.target as Node)) setAberto(false);
};
const esc = (e: KeyboardEvent) => {
if (e.key === "Escape") setAberto(false);
};
document.addEventListener("pointerdown", fora);
document.addEventListener("keydown", esc);
return () => {
document.removeEventListener("pointerdown", fora);
document.removeEventListener("keydown", esc);
};
}, [aberto]);
return (
<div ref={raiz} className="relative">
<button
ref={botao}
type="button"
aria-expanded={aberto}
aria-controls={idDoPainel}
onClick={() => setAberto((a) => !a)}
className={`flex min-h-11 w-full max-w-full cursor-pointer items-center gap-2 rounded-full border bg-page px-4 text-sm text-ink outline-accent focus-visible:outline-2 focus-visible:outline-offset-2 ${
bloco ? "" : "md:w-auto"
} ${aberto ? "border-ink" : "border-axis/50 hover:border-ink"}`}
>
<span className="text-muted">{rotulo}</span>
<span className="min-w-0 truncate font-semibold">{resumo}</span>
<svg
aria-hidden="true"
viewBox="0 0 12 12"
className={`ml-auto 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 && (
<div
id={idDoPainel}
role="group"
aria-label={rotulo}
className={`absolute top-full z-50 mt-2 max-w-[calc(100vw-3rem)] rounded-xl border border-rule bg-page shadow-flutuante ${
alinhamento === "inicio" ? "left-0" : "right-0"
} ${larguraDoPainel}`}
>
{children(() => setAberto(false))}
</div>
)}
</div>
);
}
menu-suspenso/stories.tsx
import { useState } from "react";
import { defineStories } from "@/registry/stories";
import { MenuSuspenso } from "./MenuSuspenso";
const ORDENS = ["Mais recentes", "Mais vendidos", "Menor preço", "Maior preço"];
function Exemplo({ rotulo, largura }: { rotulo: string; largura: string }) {
const [ordem, setOrdem] = useState(ORDENS[0]);
return (
<div className="flex min-h-64 items-start justify-center in-data-miniatura:min-h-0">
<MenuSuspenso rotulo={rotulo} resumo={ordem} larguraDoPainel={largura}>
{(fechar) => (
<ul className="py-1">
{ORDENS.map((o) => (
<li key={o}>
<button
type="button"
onClick={() => {
setOrdem(o);
fechar();
}}
className="flex min-h-11 w-full items-center px-3 text-left text-sm hover:bg-surface"
>
{o}
</button>
</li>
))}
</ul>
)}
</MenuSuspenso>
</div>
);
}
export default defineStories({
args: { rotulo: "Ordenar por", largura: "w-56" },
controls: {
rotulo: { type: "text" },
largura: { type: "select", label: "larguraDoPainel", options: ["w-48", "w-56", "w-72", "w-96"] },
},
render: (a) => <Exemplo rotulo={a.rotulo} largura={a.largura} />,
stories: [{ name: "Lista de opções" }, { name: "Painel largo", args: { largura: "w-96" } }],
});
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.css · Editorial
/* 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:
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);
}