Avançados: dados
Tabela stateful, assistente em passos, markdown, mural, tour guiado, transferência entre listas, barra de filtros e kanban. Cada um resolve uma tela inteira.
DataTable<T>
Tabela de dados stateful construída sobre o Table headless. Adiciona busca client-side, ordenação por clique no cabeçalho e paginação, delegando toda a marcação à Table subjacente.
import { DataTable, type DataTableColumn } from "tempest-react-sdk";
interface User {
id: number;
name: string;
email: string;
role: string;
}
const columns: DataTableColumn<User>[] = [
{ key: "name", header: "Nome", sortable: true },
{ key: "email", header: "E-mail" },
{ key: "role", header: "Papel", sortable: true, align: "right" },
];
<DataTable
data={users}
columns={columns}
searchable
pageSize={10}
initialSort={{ key: "name", direction: "asc" }}
rowKey={(row) => row.id}
emptyMessage="Nenhum usuário encontrado"
/>;
| Prop | Tipo | Default | Descrição |
|---|---|---|---|
data |
T[] |
— | Dataset completo; sort/filtro/paginação são client-side |
columns |
DataTableColumn<T>[] |
— | Definições de coluna |
pageSize |
number |
10 |
Linhas por página |
searchable |
boolean |
false |
Renderiza um input de busca acima da tabela |
searchKeys |
(keyof T)[] |
— | Chaves buscadas; default = colunas string/number |
initialSort |
DataTableSort<T> |
— | Ordenação inicial antes de interagir com o cabeçalho |
rowKey |
(row: T, index) => string \| number |
índice | Extrator de chave estável por linha |
emptyMessage |
ReactNode |
— | Conteúdo exibido quando nenhuma linha combina |
DataTableColumn<T> = { key: keyof T; header: ReactNode; render?: (row: T) => ReactNode; sortable?: boolean; align?: TableAlign; priority?: TablePriority; width?: string | number }. DataTableSort<T> = { key: keyof T; direction: "asc" | "desc" }.
Comportamento
Clicar um cabeçalho ordenável cicla asc → desc → sem ordenação. A busca combina substring case-insensitive nas searchKeys (ou em toda coluna string/number quando omitidas). A paginação some quando o resultado cabe em uma única página.
Wizard
Fluxo multi-passo: indicador, um corpo por vez e navegação que respeita validação por passo. O Stepper desenha o indicador; o Wizard é dono do que todo app reescrevia — índice ativo, gate assíncrono antes de avançar, botões desabilitados/pendentes e a chamada de conclusão.
import { Button, FormActions, FormField, Input, Wizard, useZodForm } from "tempest-react-sdk";
import { FormProvider } from "react-hook-form";
import { z } from "zod";
const schema = z.object({
nome: z.string().min(2, "Informe o nome"),
email: z.string().email("E-mail inválido"),
cep: z.string().min(9, "CEP incompleto"),
});
export function CadastroEmEtapas() {
const form = useZodForm(schema, { defaultValues: { nome: "", email: "", cep: "" } });
return (
<FormProvider {...form}>
<Wizard
nextLabel="Avançar"
backLabel="Voltar"
finishLabel="Concluir"
onComplete={form.handleSubmit((values) => console.log(values))}
steps={[
{
id: "dados",
label: "Dados",
description: "Quem é o cliente",
validate: () => form.trigger(["nome", "email"]),
content: (
<>
<FormField name="nome" label="Nome" required><Input /></FormField>
<FormField name="email" label="E-mail" required><Input type="email" /></FormField>
</>
),
},
{
id: "endereco",
label: "Endereço",
validate: () => form.trigger(["cep"]),
content: <FormField name="cep" label="CEP" required><Input /></FormField>,
},
{
id: "revisao",
label: "Revisão",
content: ({ back }) => (
<>
<pre>{JSON.stringify(form.getValues(), null, 2)}</pre>
<Button variant="ghost" onClick={back}>Corrigir</Button>
</>
),
},
]}
/>
</FormProvider>
);
}
| Prop | Tipo | Default | Descrição |
|---|---|---|---|
steps |
WizardStep[] |
— | Passos do fluxo. |
activeIndex |
number |
— | Índice controlado. |
defaultActiveIndex |
number |
0 |
Índice inicial (não controlado). |
onStepChange |
(index, step) => void |
— | Chamado a cada troca de passo. |
onComplete |
() => void \| Promise<void> |
— | Chamado quando o último passo passa na validação. |
nextLabel |
string |
"Next" |
Rótulo do botão de avanço. |
backLabel |
string |
"Back" |
Rótulo do botão de voltar. |
finishLabel |
string |
"Finish" |
Rótulo no último passo. |
optionalLabel |
string |
"(optional)" |
Sufixo do passo opcional no indicador — troque para localizar. |
clickableSteps |
boolean |
false |
Permite pular clicando no indicador. |
renderActions |
(controls: WizardControls) => ReactNode |
— | Substitui a linha de botões padrão. |
WizardStep = { id, label, description?, content, validate?, optional? } — content aceita ReactNode ou função que recebe os controles.
WizardControls = { activeIndex, step, validating, isFirst, isLast, next, back, goTo }.
Só o passo ativo está montado
Input não commitado em um passo que você abandona se perde, a menos que o estado viva fora (o FormProvider do react-hook-form, uma store, um useState do pai) — que é onde ele deveria estar de todo jeito, já que o último passo normalmente submete tudo de uma vez.
validate assíncrono já vem com estado de pendência
Enquanto a promise corre, o botão de avanço fica em loading e o de voltar desabilitado. Um validate que lança conta como "não permitido": um gate ligado a checagem de rede não deve deixar o usuário num fluxo meio-avançado quando a requisição falha.
clickableSteps é false de propósito
Um wizard existe porque a ordem importa. Com clickableSteps, pular pra trás é livre (voltar nunca bloqueia), mas pular pra frente valida cada passo atravessado — o primeiro gate que reprovar interrompe o salto ali.
Markdown
Quando usar: renderizar texto que veio de gente — comentário, descrição de ticket, release notes, corpo de mensagem.
Subconjunto de Markdown: headings, parágrafos, listas (aninhadas e numeradas), citação, código cercado (via CodeBlock), regra, tabela GFM com alinhamento, e o inline usual (**forte**, *itálico*, `código`, ~~riscado~~, link, imagem, autolink, quebra forçada).
import { Markdown } from "tempest-react-sdk";
<Markdown source={comentario.corpo} linkProps={{ target: "_blank", rel: "noreferrer" }} />;
| Prop | Tipo | Default | O que faz |
|---|---|---|---|
source |
string |
— | O Markdown. |
headingOffset |
number |
2 |
Nível que o # do documento vira. |
highlightCode |
boolean |
true |
Código cercado via CodeBlock (copiar, número de linha). |
showLineNumbers |
boolean |
false |
Número de linha no código cercado. |
linkProps |
AnchorHTMLAttributes |
— | Props extras em todo link. |
A segurança é estrutural, não uma promessa de escape
dangerouslySetInnerHTML não existe neste componente. O parser produz uma árvore de nós e o render vira elementos React — e um filho de React só pode ser texto. Então <script>alert(1)</script> num comentário renderiza como os caracteres que a pessoa digitou, e <img src=x onerror=...> também.
Isso não é "HTML sanitizado": é texto. É por isso que não há sanitizador aqui, nem lista de tags permitidas — não há caminho por onde markup entre.
URL passa por allowlist de esquema, não blocklist
Link aceita http, https, mailto, tel, sms e relativo. Imagem aceita os mesmos, mais data:image/ raster (png/jpeg/gif/webp/avif) — data:image/svg+xml fica fora de propósito: um SVG é um documento, carrega <script> e handler de evento.
[clique](javascript:alert(1)) renderiza "clique" como texto: o link cai, as palavras ficam. Blocklist teria que enumerar javascript:, JaVaScRiPt:, java\tscript:, \u0001javascript: — e erraria a que ninguém pensou. A allowlist não tem essa dívida.
É um subconjunto, e isso é o teto escolhido
Não tem HTML embutido, nota de rodapé, definition list, referência de link ([a][b]), nem lista de tarefa. Se o seu caso precisa de CommonMark completo com plugins, use react-markdown + remark direto — são 40 KB e uma cadeia de plugins que o SDK inteiro não paga. O escopo aqui é o que um comentário de usuário usa.
# vira h2 por default
Um comentário renderizado numa página cujo h1 é o título da página não pode emitir um segundo h1. O headingOffset desloca a escala inteira e o componente nunca passa de h6, então a outline continua válida.
Tabela larga rola na própria caixa, e a caixa é alcançável
A parada de tabulação aparece só enquanto o transbordo é real — área que rola sem nada focável dentro é inalcançável por teclado, e adicionar a parada sempre poluiria a ordem de tabulação com uma entrada por tabela.
Masonry
Quando usar: cards de altura desigual que não têm ordem entre si — mural de notas, galeria de fotos, cartões de dashboard.
Mede os cards e joga cada um na coluna mais curta, então a borda de baixo fica o mais reta que o conteúdo permite.
import { Masonry, Card } from "tempest-react-sdk";
<Masonry items={notas} itemKey={(nota) => nota.id} columns={{ 0: 1, 640: 2, 1024: 3 }}>
{(nota) => <Card title={nota.titulo}>{nota.corpo}</Card>}
</Masonry>;
| Prop | Tipo | Default | O que faz |
|---|---|---|---|
items |
T[] |
— | O que distribuir. |
children |
(item: T, index: number) => ReactNode |
— | Render de um card. |
columns |
number \| Record<number, number> |
{ 0: 1, 640: 2, 1024: 3 } |
Número fixo, ou mapa largura → colunas. |
itemKey |
(item: T, index: number) => string \| number |
índice | Chave estável por item. |
gap |
string |
--tempest-space-4 |
Espaço entre cards. |
Por que não é uma linha de CSS
columns do CSS quebra o card na fronteira da coluna, e grid-auto-flow: dense mantém cada linha na altura da célula mais alta — que é exatamente a borda serrilhada que se usa masonry pra evitar. As duas são uma linha de CSS e nenhuma faz este trabalho.
A ordem de leitura desce a coluna, não atravessa a linha
Card 2 fica embaixo do card 1, não ao lado. É por isso que este layout serve pra itens independentes: uma lista em que o item 2 precisa vir depois do 1 quer um grid, não isto. Se a ordem importa pro seu conteúdo, não use masonry — nem aqui, nem em CSS puro.
O mapa de breakpoints é sobre o contêiner, não sobre o viewport
Um masonry dentro de um drawer ou de uma página de duas colunas é mais estreito que a janela, e uma media query daria a ele três colunas com 300px de largura. Um ResizeObserver é o que faz { 0: 1, 640: 2 } significar "deste contêiner", que é a única leitura útil.
Coluna mais curta, não round-robin
index % colunas é o caminho óbvio e produz colunas desiguais no primeiro momento em que os itens têm alturas diferentes — que é o único motivo pra usar masonry.
Imagem que carrega depois é re-medida
Cada card é observado individualmente: altura medida na montagem erra justamente no caso da imagem que ainda estava baixando. O primeiro paint usa peso 1 pra todos (nunca aparece vazio) e o passe medido redistribui.
Tour
Quando usar: apresentar uma tela nova — onboarding de primeiro acesso, um recurso que mudou de lugar, um fluxo que ninguém acha sozinho.
Escurece a página, destaca um elemento por vez e explica. O elemento destacado continua clicável.
import { Tour } from "tempest-react-sdk";
import { useState } from "react";
export function Pedidos() {
const [aberto, setAberto] = useState(!storage.get("tour-pedidos-v1"));
return (
<>
{/* … a tela … */}
<Tour
open={aberto}
steps={[
{ target: "#novo-pedido", title: "Comece aqui", body: "Todo pedido nasce deste botão." },
{ target: "[data-tour='filtros']", body: "E filtre por período aqui.", placement: "right" },
]}
onClose={() => setAberto(false)}
onFinish={() => storage.set("tour-pedidos-v1", true)}
/>
</>
);
}
| Prop | Tipo | Default | O que faz |
|---|---|---|---|
steps |
TourStep[] |
— | As paradas, em ordem. |
open |
boolean |
— | Controlado pelo app. |
onClose |
() => void |
— | Esc, botão de fechar, "pular" ou clique no escuro. |
onFinish |
() => void |
— | Depois do último passo, antes do onClose. |
index / onIndexChange |
number / (i) => void |
interno | O app dirige o passo atual, se quiser. |
spotlightPadding |
number |
4 |
Folga em volta do elemento destacado. |
locale |
"pt-BR" \| "en" |
"pt-BR" |
Rótulos. |
TourStep = { target?, title?, body, placement? } · placement ∈ "top" | "bottom" | "left" | "right" | "center".
O elemento destacado continua clicável — e é isso que faz um coachmark útil
O escuro são quatro retângulos em volta do alvo, não um overlay com buraco de box-shadow. Motivo: sombra não é hit-testável, então um buraco feito assim não bloquearia clique nenhum — o resto da página continuaria clicável e o alvo, não. Com quatro retângulos é o contrário, que é o que "aperte este botão" precisa.
O alvo é um seletor, não um ref
Assim um tour pode ser declarado como dado — num arquivo de config, vindo do backend, ao lado da cópia — sem cada tela ter que passar refs pra quem renderiza o tour.
Passo cujo alvo não existe aparece centralizado, não desaparece
É o caso real: um recurso escondido por permissão, um botão que só existe com dado. Sumir com o passo esconderia a mensagem em silêncio, e pular pro seguinte poderia pular o tour inteiro.
Teclado: setas andam, Esc sai, foco vai pro card
O card é role="dialog" + aria-modal, nomeado pelo título e descrito pelo corpo, com armadilha de foco (useFocusTrap) e "Passo 2 de 5" visível. O Esc é tratado no card, não na window: um tour aberto sobre um modal não fecha os dois.
O card vira de lado quando não cabe — e vai pro centro quando nada cabe
Tenta o lado preferido, depois o oposto (vira relação de leitura com o alvo; pular pro lado moveria o card pela tela sem motivo visível), depois os outros. Card metade fora da tela é pior que card no meio — e isso acontece de verdade quando o alvo é mais alto que o viewport.
Persistir 'já viu' é do app
O componente recebe open e emite onClose/onFinish. Gravar a flag é uma linha no app (storage.set) e seria um default errado aqui — a chave tem versão, escopo por usuário, e às vezes mora no backend.
Transfer
Quando usar: escolher um subconjunto de um catálogo — permissões de um perfil, cidades de uma rota, membros de um grupo, colunas de um relatório.
Dois painéis, quatro controles de mover, busca em cada lado. Controlado pelos ids do lado direito; os dois painéis são derivados.
import { Transfer, type TransferItem } from "tempest-react-sdk";
import { useState } from "react";
const PERMISSOES: TransferItem[] = [
{ id: "pedidos.ler", label: "Ler pedidos" },
{ id: "pedidos.criar", label: "Criar pedidos" },
{ id: "auditoria.ler", label: "Ler auditoria", disabled: true },
];
export function PermissoesDoPerfil() {
const [permissoes, setPermissoes] = useState<string[]>([]);
return (
<Transfer
items={PERMISSOES}
value={permissoes}
onChange={setPermissoes}
sourceTitle="Disponíveis"
targetTitle="Do perfil"
/>
);
}
| Prop | Tipo | Default | O que faz |
|---|---|---|---|
items |
TransferItem[] |
— | O catálogo inteiro. Os dois painéis saem dele. |
value |
string[] |
— | Ids do lado direito. Controlado. |
onChange |
(value: string[]) => void |
— | Próximo valor, sempre na ordem do catálogo. |
sourceTitle / targetTitle |
ReactNode |
"Disponíveis" / "Selecionados" |
Título de cada painel. |
searchable |
boolean |
true acima de 8 itens |
Caixa de busca em cada painel. |
renderItem |
(item, side) => ReactNode |
item.label |
Corpo customizado da linha. |
height |
string |
"16rem" |
Altura da área de rolagem de cada painel. |
locale |
"pt-BR" \| "en" |
"pt-BR" |
Rótulos e anúncios. |
disabled |
boolean |
false |
Bloqueia todo movimento. |
TransferItem = { id, label, searchText?, disabled?, data? }
Só os ids do lado direito são estado — os painéis são derivados
Guardar duas listas parece mais simples e deriva no primeiro momento em que o catálogo muda por baixo: uma permissão removida no servidor fica pendurada no painel que a tinha, e um id que aparece nos dois lados é um bug que ninguém vê. Com um value só, items manda: o que saiu do catálogo simplesmente desaparece dos dois lados.
O botão de mover todos respeita o filtro
Filtrar por sao e clicar em "mover todos" move o que você está vendo, não o painel inteiro. Mover as linhas que o filtro escondeu é o tipo de surpresa que faz a pessoa parar de confiar no botão — e foi um bug real, pego por teste antes do merge.
Busca dobra acento, nos dois sentidos
sao acha "São Paulo" e são também. Para um público PT-BR isso não é refinamento: um includes cru falharia na metade das buscas.
Linha disabled não se move por nenhum caminho
A checagem vive no applyMove, não em cada um dos quatro botões — é uma permissão obrigatória, um assento travado. Por isso o » move "todos os movíveis", não "todos".
Depois de mover, as marcações são limpas
Senão o próximo clique no botão oposto manda tudo de volta, e o componente parece estar se desfazendo.
Os controles ficam no meio pela grade, mas por último no DOM
Um teclado que chegasse nos botões antes de ver o que eles movem teria que voltar; e um leitor de tela leria "mover marcados pra direita" sem ideia do que está marcado. Cada painel é uma region nomeada pelo título, e cada movimento é anunciado num role="status".
FilterBar
Quando usar: filtrar uma lista de admin — pedidos por status e período, usuários por papel, títulos por vencimento.
Chips com os filtros aplicados, e um editor pequeno pra adicionar outro. Os filtros são combinados com E, achatados.
import {
FilterBar,
filtersFromSearchParams,
filtersToSearchParams,
type Filter,
type FilterField,
} from "tempest-react-sdk";
const CAMPOS: FilterField[] = [
{ name: "titulo", label: "Título", type: "text" },
{ name: "total", label: "Total", type: "number" },
{ name: "criadoEm", label: "Criado em", type: "date" },
{
name: "status",
label: "Status",
type: "select",
options: [
{ value: "paid", label: "Pago" },
{ value: "sent", label: "Enviado" },
],
},
];
export function Pedidos() {
// O conjunto vem da URL, então um link compartilhado abre com os mesmos filtros.
const [filtros, setFiltros] = useState<Filter[]>(() =>
filtersFromSearchParams(new URLSearchParams(location.search), CAMPOS),
);
const { data } = useQuery({
queryKey: ["pedidos", filtros],
queryFn: () => api.get(`/pedidos?${filtersToSearchParams(filtros)}`),
});
return <FilterBar fields={CAMPOS} value={filtros} onChange={setFiltros} />;
}
| Prop | Tipo | Default | O que faz |
|---|---|---|---|
fields |
FilterField[] |
— | Campos que podem ser filtrados. |
value |
Filter[] |
— | Filtros aplicados. Controlado. |
onChange |
(filters: Filter[]) => void |
— | Próximo conjunto, combinado com E. |
actions |
ReactNode |
— | Ao lado dos controles — "salvar visão", contador. |
locale |
"pt-BR" \| "en" |
"pt-BR" |
Rótulos e descrições. |
FilterField = { name, label, type, options?, operators?, placeholder? } · type ∈ "text" | "number" | "date" | "select" | "boolean"
Filter = { field, operator, value? } · operator ∈ eq · ne · contains · gt · gte · lt · lte · between · in · empty · notEmpty
Helpers exportados: applyFilters, filtersToQueryParams, filtersToSearchParams, filtersFromSearchParams, describeFilter, operatorsFor.
Aplicando os filtros
O FilterBar produz Filter[]; quem avalia é você. O SDK dá as duas saídas, e você escolhe uma pelo tamanho da lista, não pelo gosto.
Lista inteira na memória — applyFilters roda os onze operadores:
import { FilterBar, applyFilters, type Filter } from "tempest-react-sdk";
export function Pedidos({ pedidos }: { pedidos: Pedido[] }) {
const [filtros, setFiltros] = useState<Filter[]>([]);
const visiveis = useMemo(() => applyFilters(pedidos, filtros), [pedidos, filtros]);
return (
<>
<FilterBar fields={CAMPOS} value={filtros} onChange={setFiltros} />
<DataTable data={visiveis} columns={COLUNAS} />
</>
);
}
Listagem paginada no servidor — filtersToQueryParams, que fala o dialeto do tempest-fastapi-sdk:
const params = filtersToQueryParams(filtros);
params.set("page", String(pagina));
params.set("page_size", "20");
const { data } = useQuery({
queryKey: ["pedidos", filtros, pagina],
queryFn: () => api.get(`/pedidos?${params}`),
});
| Operador | Param enviado |
|---|---|
eq |
campo (ou campo__iexact na coluna name) |
ne |
campo__ne |
contains |
campo__icontains |
gt gte lt lte |
campo__gt … campo__lte |
between |
campo__between duas vezes, menor primeiro |
in |
campo__in, uma vez por valor |
empty / notEmpty |
campo__isnull=true / =false |
O dialeto é o default, não uma lei
A tabela acima é o dialeto do tempest-fastapi-sdk. Backend diferente passa
options em vez de reescrever o encoder:
// A coluna pesquisável é `razao_social`, e `ne` se escreve à moda Django.
const params = filtersToQueryParams(filtros, {
substringColumns: ["razao_social"],
operatorSuffix: { ne: "__exclude" },
});
substringColumns— as colunas cujoeqsai comocoluna__iexact. Default["name"], que é o caso especial debuild_filter_condition. Passe[]se o seu backend não trata nenhuma coluna assim e umeqdeve sair puro.operatorSuffix— mesclado sobre o default, então o override nomeia só os operadores que diferem.
Até a v0.44.0 os dois eram constantes fechadas no módulo. Quem tinha a coluna
chamada nome ou titulo não conseguia o tratamento, e quem não tinha o caso
especial recebia um __iexact que ninguém pediu.
O backend precisa declarar cada chave, senão o filtro falha calado
BasePaginationFilterSchema.get_conditions() só repassa campos que a subclasse declara. Um status__ne que o schema não menciona é descartado pelo FastAPI antes do repositório ver — sem erro, sem filtro, e a lista volta inteira parecendo que "o filtro não pegou". Declare status__ne: str | None = None no schema de filtro para cada operador que a tela oferece.
applyFilters e o backend discordam em dois pontos, de propósito
ne casa linha sem valor. No SQL, coluna <> 'x' é NULL quando a coluna é NULL e a linha some. No cliente, "não é pago" mostra também os pedidos sem status — que é o que o chip promete.
empty casa texto em branco. __isnull no servidor só casa NULL; coluna que guarda "" em vez de NULL responde diferente dos dois lados.
Se a mesma tela alterna entre os dois modos, escolha um e fique nele por campo.
eq é sensível a maiúscula; contains não é
É o alinhamento com o servidor: eq vira WHERE coluna = valor, e um cliente insensível discordaria dele. contains vira icontains e é insensível dos dois lados — e como é o operador default de campo de texto, o comportamento amigável é o que se ganha sem pedir.
Data compara por dia, e between é inclusivo nas duas pontas
Linha carimbada 2026-03-05T13:00:00Z casa eq 2026-03-05, e um between invertido (data maior primeiro) é normalizado em vez de não casar nada — quem escolheu a data final primeiro quis o intervalo, não uma lista vazia.
É E achatado, não árvore com OU — e isso é o teto escolhido
Grupos aninhados ((a OU b) E c) são outro componente: exigem UI de árvore com operador por nó e outra serialização. Tentar ser os dois produz um builder desengonçado justamente no caso de 95% — "status é pago, criado depois de março, título contém nota". Se você precisa de OU aninhado, o que você quer é um query builder de verdade, e ele não cabe atrás desta API.
O conjunto de filtros cabe na URL — e volta dela
filtersToSearchParams escreve status=eq:paid&total=between:10|90; filtersFromSearchParams lê de volta, e tem teste de round-trip. Filtro que não sobrevive a um reload é filtro que a pessoa redigita toda vez que abre um link que alguém mandou.
O que não parseia é descartado, não adivinhado
URL editada à mão é a forma normal desse dado chegar. Um operador que o campo não oferece (total=contains:1), um campo desconhecido, um between com uma ponta só — tudo cai fora. Renderizar um chip que o backend não consegue avaliar mostraria uma lista que não corresponde ao que o chip afirma.
O chip lê em palavras, e é o mesmo texto que o leitor de tela ouve
"Status é Pago" — com o label da opção, não a chave (paid). O botão de remover usa a mesma frase no aria-label ("Remover filtro: Status é Pago"), porque um chip que diz uma coisa pra quem vê e outra pra quem ouve são duas verdades diferentes.
O input segue o campo, não o operador
Campo de data ganha date picker mesmo no between (dois deles). Digitar data em caixa de texto é o caminho mais rápido pra produzir filtro que o backend não entende.
Filtro incompleto só desabilita o Aplicar
Não é erro pra gritar — é formulário meio preenchido. Trocar o operador limpa o valor, porque valor carregado entre operadores produz filtro que ninguém quis escrever.
Kanban
Quando usar: quadro de colunas com cards que mudam de estágio — backlog, pipeline de vendas, ordens de serviço por status.
Reordena dentro da coluna e move entre colunas, por ponteiro ou teclado. A máquina de arrasto é o useSortable — o quadro não reimplementa nada disso.
import { applyKanbanMove, Kanban, type KanbanColumn } from "tempest-react-sdk";
import { useState } from "react";
export function QuadroDoBacklog() {
const [colunas, setColunas] = useState<KanbanColumn[]>([
{ id: "todo", title: "A fazer", cards: [{ id: "1", content: "Corrigir login" }] },
{ id: "doing", title: "Fazendo", cards: [] },
{ id: "done", title: "Feito", cards: [], locked: true },
]);
return (
<Kanban
label="Backlog"
columns={colunas}
onMove={(move) => setColunas((atual) => applyKanbanMove(atual, move))}
/>
);
}
| Prop | Tipo | Default | O que faz |
|---|---|---|---|
columns |
KanbanColumn[] |
— | Colunas com seus cards, em ordem de exibição. |
onMove |
(move: KanbanMove) => void |
— | Chamado uma vez por movimento confirmado. Você aplica. |
renderCard |
(card, column) => ReactNode |
conteúdo do card | Customiza o corpo do card. |
label |
string |
"Quadro" |
Nome acessível do quadro. |
emptyLabel |
ReactNode |
"Nenhum card" |
Texto da coluna vazia. |
cardRoleDescription |
string |
instrução de teclado | Anunciado por card — troque para localizar. |
disabled |
boolean |
false |
Bloqueia todo arrasto. |
KanbanColumn = { id, title, cards, locked? } · KanbanCard = { id, content } · KanbanMove = { cardId, fromColumn, toColumn, toIndex }.
applyKanbanMove(columns, move) é o reducer que aplica o movimento devolvendo arrays novos — exportado porque todo consumidor precisa do mesmo, e é onde vive o off-by-one.
Coluna locked recusa entrada, mas deixa sair
É o caso de uma coluna "Feito" que não aceita mais trabalho, mas cujos cards ainda podem voltar atrás.
Movimento por teclado só alcança posição que tem card
O movimento caminha pelo espaço de índices dos cards existentes, então soltar numa coluna vazia funciona por ponteiro, mas não por teclado. Isso é limitação da implementação atual, não desenho: enquanto teclas de troca de coluna não entrarem, o caminho é mover para uma coluna que já tenha um card e reordenar.
ARIA: um listbox por coluna, não um por quadro
Cada coluna com cards é um listbox nomeado pelo título, contendo só option. Um listbox único no quadro não sobrevive à marcação que um quadro precisa — listbox exige filhos option/group, e o cabeçalho da coluna no meio quebra essa posse. Coluna vazia não é marcada como listbox (zero option reprova aria-required-children), e o cabeçalho é div, não <header>: fora de elemento de seccionamento, todo <header> vira landmark banner — com três colunas, três banners duplicados.
Recap
- Dados:
DataTable<T>envolve oTableheadless com busca, ordenação e paginação client-side. - Todos seguem os mesmos padrões controlado/não-controlado, expõem A11y por teclado e importam de
tempest-react-sdk.