Barra de Filtros
Barra lateral de filtros para catálogos e listagens: busca, grupos de seleção múltipla com busca e contagem (listas longas) e grupos em lista (poucas opções). Pode ficar parada ao rolar a página. Vem com Filtros Ativos: as pílulas removíveis e “Limpar tudo” para pôr acima dos resultados. É a barra do catálogo desta vitrine.
Playground
Props
Geradas dos tipos TypeScript do componente; a descrição vem dos comentários /** */.
<BarraDeFiltros>
Barra lateral de filtros para catálogos e listagens: busca, grupos de seleção múltipla (listas longas, com busca e contagem) e grupos em lista (poucas opções). Com `fixa`, a partir de telas largas ela fica parada enquanto a página rola.
| Prop | Tipo | Padrão | Descrição |
|---|---|---|---|
| busca | { valor: string; aoMudar: (valor: string) => void; placeholder?: string; } | — | |
| gruposobrigatória | GrupoDeFiltro[] | — | |
| fixa | boolean | false | Fica parada ao rolar a página (position: sticky), a partir de telas largas (lg). |
| topo | string | number | 0 | Distância do topo da tela em que ela para (px ou valor CSS). Para não se mexer nada ao rolar, use a posição em que ela já começa: altura do cabeçalho fixo da página mais o espaço acima da barra. |
| rotulo | string | "Filtros" | Nome da região para leitores de tela. |
| className | string | "" |
<FiltrosAtivos>
Os filtros em uso, em pílulas que removem um por vez, mais "Limpar tudo". Fica acima dos resultados, para limpar sem precisar voltar à barra de filtros.
| Prop | Tipo | Padrão | Descrição |
|---|---|---|---|
| filtrosobrigatória | FiltroAtivo[] | — | |
| aoLimparTudo | () => void | — | |
| resumo | string | number | bigint | boolean | ReactElement<unknown, string | JSXElementConstructor<any>> | Iterable<ReactNode> | ReactPortal | Promise<AwaitedReactNode> | — | Texto antes das pílulas, como "12 de 59 resultados" (anunciado ao mudar). |
| rotuloLimpar | string | "Limpar tudo" | |
| minimoParaLimpar | number | 2 | A partir de quantos filtros "Limpar tudo" aparece (com um só, o × da pílula basta). |
Código
stories.tsx tem os exemplos usados no preview e no playground.
"use client";
import { useId, type ReactNode } from "react";
import { BarraDeBusca } from "@/library/barra-de-busca/BarraDeBusca";
import { SeletorMultiplo, type OpcaoDoSeletor, type TextosDoSeletor } from "@/library/seletor-multiplo/SeletorMultiplo";
/** Grupo com um Seletor Múltiplo: bom para listas longas (temas, marcas, autores). */
export type GrupoMultiplo = {
tipo: "multiplo";
titulo: string;
/** Texto dentro do botão ("Temas"). */
rotulo: string;
opcoes: OpcaoDoSeletor[];
selecionados: string[];
aoMudar: (selecionados: string[]) => void;
/** Busca no painel; vale a pena a partir de umas 10 opções. */
comBusca?: boolean;
rotuloDaBusca?: string;
textoSemResultados?: string;
textos?: Partial<TextosDoSeletor>;
};
export type OpcaoDaLista = { valor: string; rotulo: string; quantidade?: number };
/** Grupo em lista sempre visível, com uma escolha por vez: bom para poucas opções (tipo, status). */
export type GrupoLista = {
tipo: "lista";
titulo: string;
opcoes: OpcaoDaLista[];
/** Valor escolhido; `null` é "todos" (sem filtro). */
selecionado: string | null;
aoEscolher: (valor: string | null) => void;
/** Primeira linha, que tira o filtro (padrão "Todos"); `null` esconde a linha. */
rotuloTodos?: string | null;
/** Número mostrado na linha "Todos". */
quantidadeTotal?: number;
};
export type GrupoDeFiltro = GrupoMultiplo | GrupoLista;
// Num filtro, nada marcado quer dizer "sem restrição": os dois casos aparecem como "Todos"
const TEXTOS_DO_FILTRO: Partial<TextosDoSeletor> = { todos: "Todos", nenhum: "Todos" };
function ItemDaLista({
ativo,
aoClicar,
quantidade,
children,
}: {
ativo: boolean;
aoClicar: () => void;
quantidade?: number;
children: ReactNode;
}) {
return (
<button
type="button"
aria-pressed={ativo}
onClick={aoClicar}
className="flex w-full items-center justify-between gap-2 rounded-md px-2.5 py-1.5 text-left text-sm text-ink-2 outline-accent transition-colors hover:bg-surface hover:text-ink focus-visible:outline-2 aria-pressed:bg-ink aria-pressed:text-page"
>
<span className="truncate">{children}</span>
{quantidade !== undefined && <span className="text-xs tabular-nums opacity-60">{quantidade}</span>}
</button>
);
}
function Grupo({ grupo }: { grupo: GrupoDeFiltro }) {
const idTitulo = useId();
return (
<div role="group" aria-labelledby={idTitulo} className="space-y-2">
<h3 id={idTitulo} className="text-xs font-medium tracking-wide text-muted uppercase">
{grupo.titulo}
</h3>
{grupo.tipo === "multiplo" ? (
<SeletorMultiplo
rotulo={grupo.rotulo}
opcoes={grupo.opcoes}
selecionados={grupo.selecionados}
aoMudar={grupo.aoMudar}
textos={{ ...TEXTOS_DO_FILTRO, ...grupo.textos }}
comBusca={grupo.comBusca}
rotuloDaBusca={grupo.rotuloDaBusca}
textoSemResultados={grupo.textoSemResultados}
alinhamento="inicio"
bloco
/>
) : (
// Em telas estreitas a lista vira duas ou três colunas para não ficar comprida
<ul className="grid grid-cols-2 gap-0.5 sm:grid-cols-3 lg:grid-cols-1">
{grupo.rotuloTodos !== null && (
<li>
<ItemDaLista
ativo={grupo.selecionado === null}
aoClicar={() => grupo.aoEscolher(null)}
quantidade={grupo.quantidadeTotal}
>
{grupo.rotuloTodos ?? "Todos"}
</ItemDaLista>
</li>
)}
{grupo.opcoes.map((o) => (
<li key={o.valor}>
<ItemDaLista
ativo={grupo.selecionado === o.valor}
aoClicar={() => grupo.aoEscolher(grupo.selecionado === o.valor ? null : o.valor)}
quantidade={o.quantidade}
>
{o.rotulo}
</ItemDaLista>
</li>
))}
</ul>
)}
</div>
);
}
/**
* Barra lateral de filtros para catálogos e listagens: busca, grupos de seleção
* múltipla (listas longas, com busca e contagem) e grupos em lista (poucas opções).
* Com `fixa`, a partir de telas largas ela fica parada enquanto a página rola.
*/
export function BarraDeFiltros({
busca,
grupos,
fixa = false,
topo = 0,
rotulo = "Filtros",
className = "",
}: {
busca?: { valor: string; aoMudar: (valor: string) => void; placeholder?: string };
grupos: GrupoDeFiltro[];
/** Fica parada ao rolar a página (position: sticky), a partir de telas largas (lg). */
fixa?: boolean;
/**
* Distância do topo da tela em que ela para (px ou valor CSS). Para não se mexer
* nada ao rolar, use a posição em que ela já começa: altura do cabeçalho fixo da
* página mais o espaço acima da barra.
*/
topo?: number | string;
/** Nome da região para leitores de tela. */
rotulo?: string;
className?: string;
}) {
return (
<aside
aria-label={rotulo}
// z-10: os painéis dos seletores passam por cima do conteúdo ao lado
className={`space-y-6 ${fixa ? "lg:sticky lg:z-10 lg:self-start" : ""} ${className}`}
style={fixa ? { top: topo } : undefined}
>
{busca && (
<BarraDeBusca
value={busca.valor}
onChange={busca.aoMudar}
placeholder={busca.placeholder ?? "Buscar..."}
aria-label={busca.placeholder ?? "Buscar"}
tamanho="sm"
/>
)}
{grupos.map((grupo) => (
<Grupo key={grupo.titulo} grupo={grupo} />
))}
</aside>
);
}
export type FiltroAtivo = { chave: string; rotulo: string; aoRemover: () => void };
/**
* Os filtros em uso, em pílulas que removem um por vez, mais "Limpar tudo". Fica
* acima dos resultados, para limpar sem precisar voltar à barra de filtros.
*/
export function FiltrosAtivos({
filtros,
aoLimparTudo,
resumo,
rotuloLimpar = "Limpar tudo",
minimoParaLimpar = 2,
}: {
filtros: FiltroAtivo[];
aoLimparTudo?: () => void;
/** Texto antes das pílulas, como "12 de 59 resultados" (anunciado ao mudar). */
resumo?: ReactNode;
rotuloLimpar?: string;
/** A partir de quantos filtros "Limpar tudo" aparece (com um só, o × da pílula basta). */
minimoParaLimpar?: number;
}) {
return (
<div className="flex min-h-8 flex-wrap items-center gap-2">
{resumo && (
<p className="mr-2 text-sm text-muted" aria-live="polite">
{resumo}
</p>
)}
{filtros.map((filtro) => (
<button
key={filtro.chave}
type="button"
onClick={filtro.aoRemover}
aria-label={`Remover filtro ${filtro.rotulo}`}
className="inline-flex items-center gap-1.5 rounded-full border border-rule py-1 pr-2 pl-3 text-xs text-ink-2 outline-accent transition-colors hover:border-axis hover:bg-surface hover:text-ink focus-visible:outline-2"
>
{filtro.rotulo}
<span aria-hidden="true" className="text-sm leading-none opacity-60">
×
</span>
</button>
))}
{aoLimparTudo && filtros.length >= minimoParaLimpar && (
<button
type="button"
onClick={aoLimparTudo}
className="rounded-sm px-1 text-xs font-medium text-muted underline-offset-4 outline-accent hover:text-ink hover:underline focus-visible:outline-2"
>
{rotuloLimpar}
</button>
)}
</div>
);
}
import { useState } from "react";
import { defineStories } from "@/registry/stories";
import { BarraDeFiltros, FiltrosAtivos, type FiltroAtivo, type GrupoDeFiltro } from "./BarraDeFiltros";
type Produto = { nome: string; categoria: string; marca: string; etiquetas: string[] };
const PRODUTOS: Produto[] = [
{ nome: "Camiseta básica", categoria: "Roupas", marca: "Aurora", etiquetas: ["algodão", "lançamento"] },
{ nome: "Jaqueta corta-vento", categoria: "Roupas", marca: "Brisa", etiquetas: ["impermeável"] },
{ nome: "Tênis de corrida", categoria: "Calçados", marca: "Brisa", etiquetas: ["promoção", "esporte"] },
{ nome: "Sandália de couro", categoria: "Calçados", marca: "Cedro", etiquetas: ["couro"] },
{ nome: "Mochila 20 L", categoria: "Acessórios", marca: "Duna", etiquetas: ["impermeável", "lançamento"] },
{ nome: "Boné de linho", categoria: "Acessórios", marca: "Aurora", etiquetas: ["promoção"] },
{ nome: "Caneca esmaltada", categoria: "Casa", marca: "Cedro", etiquetas: ["presente"] },
{ nome: "Manta de tricô", categoria: "Casa", marca: "Duna", etiquetas: ["algodão", "presente"] },
{ nome: "Meia de cano alto", categoria: "Roupas", marca: "Aurora", etiquetas: ["algodão", "promoção", "esporte"] },
];
function contar(valores: string[]) {
const contagem = new Map<string, number>();
for (const v of valores) contagem.set(v, (contagem.get(v) ?? 0) + 1);
return [...contagem.entries()].sort(([a], [b]) => a.localeCompare(b, "pt-BR"));
}
const CATEGORIAS = contar(PRODUTOS.map((p) => p.categoria)).map(([valor, n]) => ({ valor, rotulo: valor, quantidade: n }));
const MARCAS = contar(PRODUTOS.map((p) => p.marca)).map(([valor, n]) => ({ valor, rotulo: valor, detalhe: n }));
const ETIQUETAS = contar(PRODUTOS.flatMap((p) => p.etiquetas)).map(([valor, n]) => ({ valor, rotulo: valor, detalhe: n }));
type Opcoes = { comBusca: boolean; comLista: boolean; comEtiquetas: boolean; buscaNasEtiquetas: boolean };
/** Estado dos filtros e os grupos montados a partir dele (usado pelas duas variações). */
function useFiltros({ comBusca, comLista, comEtiquetas, buscaNasEtiquetas }: Opcoes) {
const [busca, setBusca] = useState("");
const [categoria, setCategoria] = useState<string | null>(null);
const [marcas, setMarcas] = useState<string[]>([]);
const [etiquetas, setEtiquetas] = useState<string[]>(["promoção"]);
const grupos: GrupoDeFiltro[] = [
...(comEtiquetas
? [
{
tipo: "multiplo" as const,
titulo: "Etiquetas",
rotulo: "Etiquetas",
opcoes: ETIQUETAS,
selecionados: etiquetas,
aoMudar: setEtiquetas,
comBusca: buscaNasEtiquetas,
rotuloDaBusca: "Buscar etiqueta...",
},
]
: []),
{ tipo: "multiplo", titulo: "Marca", rotulo: "Marcas", opcoes: MARCAS, selecionados: marcas, aoMudar: setMarcas },
...(comLista
? [
{
tipo: "lista" as const,
titulo: "Categoria",
opcoes: CATEGORIAS,
selecionado: categoria,
aoEscolher: setCategoria,
quantidadeTotal: PRODUTOS.length,
},
]
: []),
];
const resultados = PRODUTOS.filter(
(p) =>
(!categoria || p.categoria === categoria) &&
(marcas.length === 0 || marcas.includes(p.marca)) &&
(etiquetas.length === 0 || etiquetas.some((e) => p.etiquetas.includes(e))) &&
p.nome.toLowerCase().includes(busca.trim().toLowerCase()),
);
const ativos: FiltroAtivo[] = [
...(busca.trim() ? [{ chave: "busca", rotulo: `Busca: “${busca.trim()}”`, aoRemover: () => setBusca("") }] : []),
...(categoria ? [{ chave: "categoria", rotulo: `Categoria: ${categoria}`, aoRemover: () => setCategoria(null) }] : []),
...marcas.map((m) => ({ chave: `m-${m}`, rotulo: `Marca: ${m}`, aoRemover: () => setMarcas(marcas.filter((x) => x !== m)) })),
...etiquetas.map((e) => ({
chave: `e-${e}`,
rotulo: `Etiqueta: ${e}`,
aoRemover: () => setEtiquetas(etiquetas.filter((x) => x !== e)),
})),
];
const limparTudo = () => {
setBusca("");
setCategoria(null);
setMarcas([]);
setEtiquetas([]);
};
return { busca: comBusca ? { valor: busca, aoMudar: setBusca, placeholder: "Buscar produto..." } : undefined, grupos, resultados, ativos, limparTudo };
}
function SoABarra(opcoes: Opcoes) {
const { busca, grupos } = useFiltros(opcoes);
// Espaço abaixo para os painéis dos seletores abrirem dentro do preview
return (
<div className="min-h-[34rem] w-64">
<BarraDeFiltros busca={busca} grupos={grupos} />
</div>
);
}
function ComResultados() {
const { busca, grupos, resultados, ativos, limparTudo } = useFiltros({
comBusca: true,
comLista: true,
comEtiquetas: true,
buscaNasEtiquetas: true,
});
return (
<div className="flex min-h-[34rem] w-full max-w-5xl flex-col gap-6 lg:flex-row">
<BarraDeFiltros busca={busca} grupos={grupos} className="lg:w-60 lg:shrink-0" />
<div className="min-w-0 flex-1 space-y-4">
<FiltrosAtivos
filtros={ativos}
aoLimparTudo={limparTudo}
resumo={`${resultados.length} de ${PRODUTOS.length} produtos`}
/>
<ul className="grid grid-cols-[repeat(auto-fill,minmax(10rem,1fr))] gap-3">
{resultados.map((p) => (
<li key={p.nome} className="rounded-xl border border-rule bg-surface p-4">
<p className="font-medium">{p.nome}</p>
<p className="mt-1 text-xs text-muted">
{p.categoria} · {p.marca}
</p>
</li>
))}
</ul>
</div>
</div>
);
}
export default defineStories({
args: { comBusca: true, comLista: true, comEtiquetas: true, buscaNasEtiquetas: true },
controls: {
comBusca: { type: "boolean", label: "busca" },
comLista: { type: "boolean", label: "grupo em lista (Categoria)" },
comEtiquetas: { type: "boolean", label: "grupo múltiplo longo (Etiquetas)" },
buscaNasEtiquetas: { type: "boolean", label: "comBusca no grupo de etiquetas" },
},
render: (a) => <SoABarra {...a} />,
stories: [
{ name: "Barra de filtros" },
{ name: "Com resultados e filtros ativos", render: () => <ComResultados /> },
{
name: "Filtros ativos",
render: () => (
<FiltrosAtivos
resumo="3 de 9 produtos"
filtros={[
{ chave: "a", rotulo: "Marca: Aurora", aoRemover: () => {} },
{ chave: "b", rotulo: "Etiqueta: promoção", aoRemover: () => {} },
]}
aoLimparTudo={() => {}}
/>
),
},
],
});
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", 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);
}