extraído de climatempo
Tabela de Dados
Tabela tipada por colunas (texto, número, decimal, percentual ou formatação própria), com números no formato brasileiro, tooltip nos títulos e rolagem horizontal em telas estreitas.
Variações
Playground
Props
Geradas dos tipos TypeScript do componente; a descrição vem dos comentários /** */.
<TabelaDados>
Tabela de dados tipada por colunas, com formatação brasileira e rolagem horizontal em telas estreitas. Colunas podem ser ordenáveis e o cabeçalho pode ficar fixo enquanto o corpo rola.
| Prop | Tipo | Padrão | Descrição |
|---|---|---|---|
| colunasobrigatória | Coluna<T>[] | — | |
| linhasobrigatória | T[] | — | |
| legendaobrigatória | string | — | Descrição da tabela para leitores de tela. |
| larguraMinima | string | "40rem" | Abaixo desta largura a tabela rola na horizontal em vez de espremer as colunas. |
| cabecalhoFixo | boolean | false | Mantém o cabeçalho no topo enquanto as linhas rolam. A tabela passa a rolar dentro da própria caixa, com a altura de `alturaMaxima` (padrão: 70vh). |
| alturaMaxima | string | — | Altura máxima da caixa ("24rem", "60vh"); acima dela, as linhas rolam dentro da tabela. |
| fundoDoCabecalho | "page" | "surface" | "page" | Fundo do cabeçalho fixo: o mesmo do que está atrás da tabela, para as linhas não aparecerem por baixo. |
| ordenacaoInicial | Ordenacao<T> | — | Ordenação ao abrir (só vale com colunas ordenáveis). |
Código
stories.tsx tem os exemplos usados no preview e no playground.
tabela-dados/desenho.tsx
import type { CSSProperties, ReactNode } from "react";
import { Dica, Termo } from "@/library/dica/Dica";
import type { Coluna, Ordenacao } from "./TabelaDados";
// O desenho da tabela, sem hooks: serve à TabelaDados (que continua podendo ser
// usada em Server Components) e à versão ordenável, que guarda a ordenação no estado.
const NUMERICO = new Set(["numero", "decimal", "percentual"]);
const numero = (v: number, casas: number) =>
v.toLocaleString("pt-BR", { minimumFractionDigits: casas, maximumFractionDigits: casas });
function formatarCelula<T>(linha: T, coluna: Coluna<T>): ReactNode {
if (coluna.formatar) return coluna.formatar(linha);
const valor: unknown = linha[coluna.chave];
if (valor === null || valor === undefined) return "—";
const tipo = coluna.tipo ?? "texto";
if (tipo === "texto") return String(valor);
if (typeof valor !== "number" || Number.isNaN(valor)) return "—";
if (tipo === "percentual") return `${numero(valor, 1)}%`;
// "numero": inteiros como estão; "decimal": 2 casas.
const casas = tipo === "decimal" ? 2 : Number.isInteger(valor) ? 0 : 1;
return coluna.sufixo ? `${numero(valor, casas)} ${coluna.sufixo}` : numero(valor, casas);
}
// ─── Ordenação ─────────────────────────────────────────────────────────────────
const vazio = (v: unknown) => v === null || v === undefined || (typeof v === "number" && Number.isNaN(v));
function comparar(a: unknown, b: unknown): number {
if (typeof a === "number" && typeof b === "number") return a - b;
if (a instanceof Date && b instanceof Date) return a.getTime() - b.getTime();
if (typeof a === "boolean" && typeof b === "boolean") return Number(a) - Number(b);
return String(a).localeCompare(String(b), "pt-BR", { numeric: true, sensitivity: "base" });
}
/** Cópia das linhas na ordem pedida. Valores vazios ficam no fim nas duas direções (salvo com `ordenarPor`). */
export function ordenarLinhas<T>(linhas: T[], colunas: Coluna<T>[], ordenacao: Ordenacao<T> | null): T[] {
const coluna = ordenacao && colunas.find((c) => c.chave === ordenacao.chave);
if (!ordenacao || !coluna) return linhas;
const sinal = ordenacao.direcao === "asc" ? 1 : -1;
return [...linhas].sort((a, b) => {
if (coluna.ordenarPor) return sinal * coluna.ordenarPor(a, b);
const va = a[coluna.chave];
const vb = b[coluna.chave];
if (vazio(va) || vazio(vb)) return vazio(va) === vazio(vb) ? 0 : vazio(va) ? 1 : -1;
return sinal * comparar(va, vb);
});
}
/** Ciclo do clique no cabeçalho: crescente → decrescente → sem ordenação. */
export function proximaOrdenacao<T>(atual: Ordenacao<T> | null, chave: keyof T & string): Ordenacao<T> | null {
if (atual?.chave !== chave) return { chave, direcao: "asc" };
return atual.direcao === "asc" ? { chave, direcao: "desc" } : null;
}
function IconeDeOrdem({ direcao }: { direcao: "asc" | "desc" | null }) {
return (
<svg
aria-hidden="true"
viewBox="0 0 24 24"
className={`size-3.5 shrink-0 ${direcao ? "text-accent" : "text-muted"}`}
fill="none"
stroke="currentColor"
strokeWidth={2.2}
strokeLinecap="round"
strokeLinejoin="round"
>
{direcao === "asc" && <path d="M12 19V5m-6 6 6-6 6 6" />}
{direcao === "desc" && <path d="M12 5v14m-6-6 6 6 6-6" />}
{direcao === null && <path d="m8 9 4-4 4 4M8 15l4 4 4-4" />}
</svg>
);
}
// ─── Desenho ───────────────────────────────────────────────────────────────────
export type DesenhoDaTabela<T> = {
colunas: Coluna<T>[];
linhas: T[];
legenda: string;
larguraMinima: string;
cabecalhoFixo: boolean;
alturaMaxima?: string;
fundoDoCabecalho: "page" | "surface";
/** Só na versão ordenável. */
ordenacao?: Ordenacao<T> | null;
aoOrdenar?: (chave: keyof T & string) => void;
};
export function desenharTabela<T extends object>({
colunas,
linhas,
legenda,
larguraMinima,
cabecalhoFixo,
alturaMaxima,
fundoDoCabecalho,
ordenacao,
aoOrdenar,
}: DesenhoDaTabela<T>) {
// O cabeçalho só gruda se a própria caixa rolar na vertical: a rolagem horizontal
// dela já impede que ele grude no topo da página.
const limite = alturaMaxima ?? (cabecalhoFixo ? "70vh" : undefined);
return (
// -mr-3/pr-3: o ícone de explicação da última coluna tem uma área de toque que passa 12 px da borda
// direita; sem essa folga ela contaria como conteúdo e apareceria uma rolagem horizontal à toa.
<div className={`-mr-3 pr-3 ${limite ? "overflow-auto" : "overflow-x-auto"}`} style={limite ? { maxHeight: limite } : undefined}>
<table
style={{ minWidth: larguraMinima } as CSSProperties}
className="w-full border-collapse text-base tabular-nums"
>
<caption className="sr-only">{legenda}</caption>
<thead>
<tr>
{colunas.map((c) => {
const numerico = NUMERICO.has(c.tipo ?? "texto");
const direcao = ordenacao?.chave === c.chave ? ordenacao.direcao : null;
const ordenavel = c.ordenavel && aoOrdenar;
return (
<th
key={c.chave}
scope="col"
aria-sort={direcao === "asc" ? "ascending" : direcao === "desc" ? "descending" : undefined}
className={`px-3 py-2.5 text-xs font-semibold whitespace-nowrap text-ink-2 first:pl-0 last:pr-0 ${
numerico ? "text-right" : "text-left"
} ${
// Fixo, a linha de baixo vira sombra: com border-collapse a borda não acompanha a célula
cabecalhoFixo
? `sticky top-0 z-10 shadow-[inset_0_-2px_0_var(--color-rule-strong)] ${fundoDoCabecalho === "surface" ? "bg-surface" : "bg-page"}`
: "border-b-2 border-rule-strong"
}`}
>
{ordenavel ? (
// O botão de verdade (e não um onClick no th) entra no Tab e responde a Enter/Espaço
// Em colunas numéricas a seta vai antes do título, que fica rente à direita como os números
<span className="inline-flex items-center gap-1">
<button
type="button"
onClick={() => aoOrdenar(c.chave)}
className={`-mx-1 inline-flex cursor-pointer items-center gap-1 rounded-sm px-1 font-semibold outline-accent hover:text-ink focus-visible:outline-2 ${
numerico ? "flex-row-reverse" : ""
} ${direcao ? "text-ink" : ""}`}
>
{c.titulo}
<IconeDeOrdem direcao={direcao} />
</button>
{c.dica && <Dica texto={c.dica} termo={c.titulo} />}
</span>
) : c.dica ? (
<Termo dica={c.dica}>{c.titulo}</Termo>
) : (
c.titulo
)}
</th>
);
})}
</tr>
</thead>
<tbody>
{linhas.map((linha, i) => (
<tr key={i} className="border-b border-rule">
{colunas.map((c, j) => {
const Celula = j === 0 ? "th" : "td";
return (
<Celula
key={c.chave}
scope={j === 0 ? "row" : undefined}
className={`px-3 py-3 align-top whitespace-nowrap first:pl-0 last:pr-0 ${
NUMERICO.has(c.tipo ?? "texto") ? "text-right" : "text-left"
} ${j === 0 ? "font-semibold" : "font-normal"}`}
>
{formatarCelula(linha, c)}
</Celula>
);
})}
</tr>
))}
</tbody>
</table>
</div>
);
}
tabela-dados/TabelaDados.tsx
import type { ReactNode } from "react";
import { desenharTabela } from "./desenho";
import { TabelaOrdenavel } from "./TabelaOrdenavel";
export type Coluna<T> = {
chave: keyof T & string;
titulo: string;
/** Define o alinhamento e a formatação padrão (padrão: "texto"). */
tipo?: "texto" | "numero" | "decimal" | "percentual";
/** Unidade depois do número ("°C", "mm", "kg"). */
sufixo?: string;
/** Explicação do termo, mostrada num tooltip ao lado do título. */
dica?: string;
/** Formatação própria da célula, no lugar da padrão do tipo. */
formatar?: (linha: T) => ReactNode;
/**
* O título vira um botão que ordena pela coluna: crescente, decrescente e de
* volta à ordem original. Números, datas e textos (em português) já se comparam;
* valores vazios ficam no fim.
*/
ordenavel?: boolean;
/** Comparação própria para a ordem crescente (negativo: `a` vem antes). */
ordenarPor?: (a: T, b: T) => number;
};
/** Coluna e direção da ordenação. */
export type Ordenacao<T> = { chave: keyof T & string; direcao: "asc" | "desc" };
/**
* Tabela de dados tipada por colunas, com formatação brasileira e rolagem horizontal em telas estreitas.
* Colunas podem ser ordenáveis e o cabeçalho pode ficar fixo enquanto o corpo rola.
*/
export function TabelaDados<T extends object>({
colunas,
linhas,
legenda,
larguraMinima = "40rem",
cabecalhoFixo = false,
alturaMaxima,
fundoDoCabecalho = "page",
ordenacaoInicial,
}: {
colunas: Coluna<T>[];
linhas: T[];
/** Descrição da tabela para leitores de tela. */
legenda: string;
/** Abaixo desta largura a tabela rola na horizontal em vez de espremer as colunas. */
larguraMinima?: string;
/**
* Mantém o cabeçalho no topo enquanto as linhas rolam. A tabela passa a rolar
* dentro da própria caixa, com a altura de `alturaMaxima` (padrão: 70vh).
*/
cabecalhoFixo?: boolean;
/** Altura máxima da caixa ("24rem", "60vh"); acima dela, as linhas rolam dentro da tabela. */
alturaMaxima?: string;
/** Fundo do cabeçalho fixo: o mesmo do que está atrás da tabela, para as linhas não aparecerem por baixo. */
fundoDoCabecalho?: "page" | "surface";
/** Ordenação ao abrir (só vale com colunas ordenáveis). */
ordenacaoInicial?: Ordenacao<T>;
}) {
const desenho = { colunas, linhas, legenda, larguraMinima, cabecalhoFixo, alturaMaxima, fundoDoCabecalho };
// Só a versão ordenável usa estado (e é client component); sem ordenação, a tabela continua sem hooks
if (colunas.some((c) => c.ordenavel)) return <TabelaOrdenavel {...desenho} ordenacaoInicial={ordenacaoInicial} />;
return desenharTabela(desenho);
}
tabela-dados/TabelaOrdenavel.tsx
"use client";
import { useState } from "react";
import { desenharTabela, ordenarLinhas, proximaOrdenacao, type DesenhoDaTabela } from "./desenho";
import type { Ordenacao } from "./TabelaDados";
/**
* A TabelaDados quando alguma coluna é ordenável: guarda a ordenação no estado.
* Fica num arquivo "use client" à parte para a tabela sem ordenação seguir sem hooks.
* (Constante e não `function`: a vitrine documenta só a TabelaDados.)
*/
export const TabelaOrdenavel = <T extends object>({
ordenacaoInicial,
...props
}: Omit<DesenhoDaTabela<T>, "ordenacao" | "aoOrdenar"> & { ordenacaoInicial?: Ordenacao<T> }) => {
const [ordenacao, setOrdenacao] = useState<Ordenacao<T> | null>(ordenacaoInicial ?? null);
return desenharTabela({
...props,
linhas: ordenarLinhas(props.linhas, props.colunas, ordenacao),
ordenacao,
aoOrdenar: (chave) => setOrdenacao((atual) => proximaOrdenacao(atual, chave)),
});
};
tabela-dados/stories.tsx
import { defineStories } from "@/registry/stories";
import { TabelaDados, type Coluna } from "./TabelaDados";
type Produto = { nome: string; pedidos: number; receita: number; peso: number; margem: number | null };
const PRODUTOS: Produto[] = [
{ nome: "Café especial 250 g", pedidos: 1284, receita: 48210.5, peso: 0.25, margem: 38.4 },
{ nome: "Moedor manual", pedidos: 312, receita: 40560, peso: 0.68, margem: 27.9 },
{ nome: "Prensa francesa", pedidos: 498, receita: 34362, peso: 0.9, margem: 31.2 },
{ nome: "Filtro de papel (100 un.)", pedidos: 2051, receita: 18459, peso: 0.12, margem: 52.6 },
{ nome: "Chaleira bico de ganso", pedidos: 141, receita: 26085, peso: 1.1, margem: null },
{ nome: "Balança de precisão", pedidos: 97, receita: 15423, peso: 0.45, margem: 22.4 },
];
function colunas(comDicas: boolean, ordenavel = false): Coluna<Produto>[] {
const lista: Coluna<Produto>[] = [
{ chave: "nome", titulo: "Produto" },
{ chave: "pedidos", titulo: "Pedidos", tipo: "numero" },
{
chave: "receita",
titulo: "Receita",
tipo: "decimal",
formatar: (p) => p.receita.toLocaleString("pt-BR", { style: "currency", currency: "BRL" }),
},
{ chave: "peso", titulo: "Peso", tipo: "decimal", sufixo: "kg" },
{
chave: "margem",
titulo: "Margem",
tipo: "percentual",
dica: comDicas ? "Receita menos custo, sobre a receita. Travessão quando ainda não há custo cadastrado." : undefined,
},
];
return lista.map((c) => ({ ...c, ordenavel }));
}
type Tarefa = { titulo: string; responsavel: string; prioridade: "Alta" | "Média" | "Baixa"; prazo: string; horas: number };
const PESO = { Alta: 0, Média: 1, Baixa: 2 };
const RESPONSAVEIS = ["Ana", "Bruno", "Carla", "Diego", "Elisa"];
const TAREFAS: Tarefa[] = [
"Revisar contrato",
"Atualizar catálogo",
"Fechar o caixa",
"Treinar equipe",
"Conferir estoque",
"Responder clientes",
"Planejar campanha",
"Ajustar preços",
"Auditar notas",
"Renovar alvará",
"Organizar depósito",
"Montar vitrine",
"Negociar frete",
"Enviar relatório",
].map((titulo, i) => ({
titulo,
responsavel: RESPONSAVEIS[i % RESPONSAVEIS.length],
prioridade: (["Alta", "Média", "Baixa"] as const)[(i * 2) % 3],
prazo: `2026-10-${String(((i * 5) % 27) + 1).padStart(2, "0")}`,
horas: ((i * 5) % 17) + 1.5,
}));
const COLUNAS_DE_TAREFAS: Coluna<Tarefa>[] = [
{ chave: "titulo", titulo: "Tarefa", ordenavel: true },
{ chave: "responsavel", titulo: "Responsável", ordenavel: true },
// A ordem natural da prioridade não é a alfabética: comparação própria
{ chave: "prioridade", titulo: "Prioridade", ordenavel: true, ordenarPor: (a, b) => PESO[a.prioridade] - PESO[b.prioridade] },
{
chave: "prazo",
titulo: "Prazo",
ordenavel: true,
formatar: (t) => t.prazo.split("-").reverse().slice(0, 2).join("/"),
},
{ chave: "horas", titulo: "Horas", tipo: "numero", sufixo: "h", ordenavel: true },
];
export default defineStories({
args: { linhas: 4, comDicas: true, ordenavel: false, larguraMinima: "40rem", legenda: "Vendas por produto" },
controls: {
linhas: { type: "number", label: "linhas (quantidade)", min: 0, max: 6 },
comDicas: { type: "boolean", label: "dica no título da coluna" },
ordenavel: { type: "boolean", label: "colunas ordenáveis" },
larguraMinima: { type: "select", options: ["30rem", "40rem", "50rem"] },
legenda: { type: "text" },
},
render: (a) => (
<div className="w-full">
<TabelaDados
colunas={colunas(a.comDicas, a.ordenavel)}
linhas={PRODUTOS.slice(0, a.linhas)}
legenda={a.legenda}
larguraMinima={a.larguraMinima}
/>
</div>
),
stories: [
{ name: "Produtos" },
{ name: "Com valor ausente", args: { linhas: 6 } },
{ name: "Sem dicas", args: { comDicas: false } },
{ name: "Ordenável", args: { linhas: 6, ordenavel: true } },
{
name: "Cabeçalho fixo e ordem própria",
render: () => (
<div className="w-full">
<TabelaDados
colunas={COLUNAS_DE_TAREFAS}
linhas={TAREFAS}
legenda="Tarefas da equipe"
cabecalhoFixo
alturaMaxima="20rem"
ordenacaoInicial={{ chave: "prioridade", direcao: "asc" }}
/>
</div>
),
},
],
});
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);
}