extraído de climatempo
Seletor Múltiplo
Seleção múltipla num menu suspenso, com atalhos para marcar todos e limpar. O botão resume a escolha (“Todos”, “Recife, Olinda”, “3 de 8”) e os textos são configuráveis.
Variações
Playground
Props
Geradas dos tipos TypeScript do componente; a descrição vem dos comentários /** */.
<SeletorMultiplo>
Caixa de seleção múltipla: um botão com o resumo da escolha que abre uma lista de caixas de marcar, com atalhos para marcar tudo e limpar e, opcionalmente, uma busca no topo do painel.
| Prop | Tipo | Padrão | Descrição |
|---|---|---|---|
| rotuloobrigatória | string | — | |
| opcoesobrigatória | OpcaoDoSeletor[] | — | |
| selecionadosobrigatória | string[] | — | |
| aoMudarobrigatória | (selecionados: string[]) => void | — | |
| textos | Partial<TextosDoSeletor> | — | |
| alinhamento | "inicio" | "fim" | — | Lado em que o painel se alinha ao botão (veja o Menu Suspenso). |
| bloco | boolean | — | Botão com a largura toda do contêiner. |
| comBusca | boolean | false | Campo de busca no topo do painel (sem diferenciar maiúsculas e acentos). Com busca, marcar todos e limpar valem para as opções encontradas. |
| rotuloDaBusca | string | "Buscar..." | Texto de exemplo e nome do campo de busca. |
| textoSemResultados | string | "Nenhum resultado." | Mensagem quando a busca não encontra nenhuma opção. |
Código
stories.tsx tem os exemplos usados no preview e no playground.
seletor-multiplo/SeletorMultiplo.tsx
"use client";
import { useState } from "react";
import { MenuSuspenso } from "@/library/menu-suspenso/MenuSuspenso";
export type OpcaoDoSeletor = {
valor: string;
rotulo: string;
cor?: string;
/** Texto curto à direita da opção, como uma contagem ("12"). */
detalhe?: string | number;
};
/** Textos do seletor; troque para concordar com o que é escolhido ("Todas", "Nenhuma"...). */
export type TextosDoSeletor = { todos: string; nenhum: string; marcarTodos: string; limpar: string };
const TEXTOS_PADRAO: TextosDoSeletor = {
todos: "Todos",
nenhum: "Nenhum",
marcarTodos: "Marcar todos",
limpar: "Limpar",
};
/** "Todos", "Recife", "Recife, Olinda" ou "3 de 8": cabe no botão sem quebrar a barra. */
function resumir(opcoes: OpcaoDoSeletor[], selecionados: string[], textos: TextosDoSeletor): string {
if (selecionados.length === 0) return textos.nenhum;
if (selecionados.length === opcoes.length) return textos.todos;
if (selecionados.length <= 2) {
return opcoes
.filter((o) => selecionados.includes(o.valor))
.map((o) => o.rotulo)
.join(", ");
}
return `${selecionados.length} de ${opcoes.length}`;
}
const BOTAO_DE_ACAO =
"cursor-pointer rounded-sm px-1 text-sm font-medium text-accent underline-offset-2 outline-accent hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 disabled:cursor-default disabled:text-muted disabled:no-underline";
/** Para buscar sem diferenciar maiúsculas nem acentos ("ceara" acha "Ceará"). */
const normalizar = (texto: string) =>
texto.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLocaleLowerCase("pt-BR").trim();
/**
* Caixa de seleção múltipla: um botão com o resumo da escolha que abre uma lista
* de caixas de marcar, com atalhos para marcar tudo e limpar e, opcionalmente, uma
* busca no topo do painel.
*/
export function SeletorMultiplo({
rotulo,
opcoes,
selecionados,
aoMudar,
textos: textosParciais,
comBusca = false,
rotuloDaBusca = "Buscar...",
textoSemResultados = "Nenhum resultado.",
alinhamento,
bloco,
}: {
rotulo: string;
opcoes: OpcaoDoSeletor[];
selecionados: string[];
aoMudar: (selecionados: string[]) => void;
textos?: Partial<TextosDoSeletor>;
/** Lado em que o painel se alinha ao botão (veja o Menu Suspenso). */
alinhamento?: "inicio" | "fim";
/** Botão com a largura toda do contêiner. */
bloco?: boolean;
/** Campo de busca no topo do painel (sem diferenciar maiúsculas e acentos). Com busca, marcar todos e limpar valem para as opções encontradas. */
comBusca?: boolean;
/** Texto de exemplo e nome do campo de busca. */
rotuloDaBusca?: string;
/** Mensagem quando a busca não encontra nenhuma opção. */
textoSemResultados?: string;
}) {
const textos = { ...TEXTOS_PADRAO, ...textosParciais };
// O painel só existe aberto: a busca recomeça vazia a cada abertura
return (
<MenuSuspenso rotulo={rotulo} resumo={resumir(opcoes, selecionados, textos)} alinhamento={alinhamento} bloco={bloco}>
{() => (
<PainelDoSeletor
opcoes={opcoes}
selecionados={selecionados}
aoMudar={aoMudar}
textos={textos}
comBusca={comBusca}
rotuloDaBusca={rotuloDaBusca}
textoSemResultados={textoSemResultados}
/>
)}
</MenuSuspenso>
);
}
function PainelDoSeletor({
opcoes,
selecionados,
aoMudar,
textos,
comBusca,
rotuloDaBusca,
textoSemResultados,
}: {
opcoes: OpcaoDoSeletor[];
selecionados: string[];
aoMudar: (selecionados: string[]) => void;
textos: TextosDoSeletor;
comBusca: boolean;
rotuloDaBusca: string;
textoSemResultados: string;
}) {
const [busca, setBusca] = useState("");
const termo = comBusca ? normalizar(busca) : "";
const visiveis = termo ? opcoes.filter((o) => normalizar(o.rotulo).includes(termo)) : opcoes;
// Mantém a ordem das opções no resultado
const emOrdem = (valores: Set<string>) => opcoes.map((o) => o.valor).filter((v) => valores.has(v));
const alternar = (valor: string) =>
aoMudar(
selecionados.includes(valor)
? selecionados.filter((v) => v !== valor)
: emOrdem(new Set([...selecionados, valor])),
);
// Sem busca, valem para todas as opções; com busca, só para as encontradas
const marcarTodos = () =>
aoMudar(termo ? emOrdem(new Set([...selecionados, ...visiveis.map((o) => o.valor)])) : opcoes.map((o) => o.valor));
const limpar = () => {
const encontradas = new Set(visiveis.map((o) => o.valor));
aoMudar(termo ? selecionados.filter((v) => !encontradas.has(v)) : []);
};
const todasMarcadas = termo
? visiveis.every((o) => selecionados.includes(o.valor))
: selecionados.length === opcoes.length;
const nenhumaMarcada = termo ? !visiveis.some((o) => selecionados.includes(o.valor)) : selecionados.length === 0;
return (
<>
{comBusca && (
<div className="border-b border-rule p-2">
<label className="flex min-h-10 items-center gap-2 rounded-[max(var(--radius-sm),calc(var(--radius-xl)_-_var(--spacing)*2))] border border-rule bg-surface px-3 text-sm outline-accent focus-within:border-accent focus-within:outline-2 focus-within:outline-offset-1">
<svg aria-hidden="true" viewBox="0 0 24 24" className="size-4 shrink-0 text-muted" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round">
<circle cx="11" cy="11" r="6.5" />
<path d="m16 16 4 4" />
</svg>
<input
// Abre já pronto para digitar
autoFocus
value={busca}
onChange={(e) => setBusca(e.target.value)}
onKeyDown={(e) => {
// Não envia o formulário em volta
if (e.key === "Enter") e.preventDefault();
}}
placeholder={rotuloDaBusca}
aria-label={rotuloDaBusca}
autoComplete="off"
className="min-w-0 flex-1 bg-transparent py-2 text-ink outline-none placeholder:text-muted"
/>
</label>
</div>
)}
<div className="flex items-center justify-between border-b border-rule px-3 py-2">
<button type="button" className={BOTAO_DE_ACAO} disabled={visiveis.length === 0 || todasMarcadas} onClick={marcarTodos}>
{textos.marcarTodos}
</button>
<button type="button" className={BOTAO_DE_ACAO} disabled={visiveis.length === 0 || nenhumaMarcada} onClick={limpar}>
{textos.limpar}
</button>
</div>
{visiveis.length === 0 ? (
<p role="status" className="px-3 py-4 text-sm text-muted">
{textoSemResultados}
</p>
) : (
<ul className="max-h-72 overflow-y-auto py-1">
{visiveis.map((o) => (
<li key={o.valor}>
<label className="flex min-h-11 cursor-pointer items-center gap-3 px-3 text-sm hover:bg-surface">
<input
type="checkbox"
checked={selecionados.includes(o.valor)}
onChange={() => alternar(o.valor)}
className="size-4 shrink-0 cursor-pointer accent-accent"
/>
{o.cor && <span aria-hidden="true" className="size-2.5 shrink-0 rounded-full" style={{ background: o.cor }} />}
<span className="min-w-0 truncate">{o.rotulo}</span>
{o.detalhe !== undefined && (
<span className="ml-auto shrink-0 text-xs text-muted tabular-nums">{o.detalhe}</span>
)}
</label>
</li>
))}
</ul>
)}
</>
);
}
seletor-multiplo/stories.tsx
import { useState } from "react";
import { defineStories } from "@/registry/stories";
import { SeletorMultiplo } from "./SeletorMultiplo";
const ESTADOS = [
"Bahia",
"Ceará",
"Goiás",
"Minas Gerais",
"Pará",
"Paraná",
"Pernambuco",
"Rio de Janeiro",
"Rio Grande do Sul",
"São Paulo",
];
const NO_FEMININO = { todos: "Todas", nenhum: "Nenhuma", marcarTodos: "Marcar todas" };
function Exemplo({
rotulo,
quantidade,
comCores,
feminino,
comBusca,
}: {
rotulo: string;
quantidade: number;
comCores: boolean;
feminino: boolean;
comBusca: boolean;
}) {
const opcoes = ESTADOS.slice(0, quantidade).map((nome, i) => ({
valor: nome,
rotulo: nome,
cor: comCores ? (i < 8 ? `var(--series-${i + 1})` : "var(--muted)") : undefined,
}));
const [selecionados, setSelecionados] = useState(opcoes.slice(0, 2).map((o) => o.valor));
return (
<div className="flex min-h-96 items-start justify-center in-data-miniatura:min-h-0">
<SeletorMultiplo
rotulo={rotulo}
opcoes={opcoes}
selecionados={selecionados.filter((s) => opcoes.some((o) => o.valor === s))}
aoMudar={setSelecionados}
textos={feminino ? NO_FEMININO : undefined}
comBusca={comBusca}
rotuloDaBusca={feminino ? "Buscar região..." : "Buscar estado..."}
/>
</div>
);
}
export default defineStories({
args: { rotulo: "Regiões", quantidade: 5, comCores: true, feminino: true, comBusca: false },
controls: {
rotulo: { type: "text" },
quantidade: { type: "number", label: "opções (quantidade)", min: 2, max: 10 },
comCores: { type: "boolean", label: "cor em cada opção" },
feminino: { type: "boolean", label: "textos no feminino (Todas/Nenhuma)" },
comBusca: { type: "boolean", label: "busca no painel" },
},
render: (a) => <Exemplo key={a.quantidade} {...a} />,
stories: [
{ name: "Com cores" },
{ name: "Sem cores", args: { rotulo: "Lojas", comCores: false, feminino: true } },
{ name: "Muitas opções", args: { rotulo: "Estados", quantidade: 10, feminino: false } },
{ name: "Com busca", args: { rotulo: "Estados", quantidade: 10, feminino: false, comBusca: true } },
],
});
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.
Componentes da biblioteca (diretos e indiretos)
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);
}