08 — Componentes
08 — Componentes
A regra dos três arquivos, o catálogo de
common/e quando criar em vez de reaproveitar. Componente não chama caso de uso. Recebe dados e callbacks por props. Catálogo verificado: os componentes abaixo foram portados e testados de verdade (tsc/lint/build), não apenas lidos de uma implementação de referência — onde algo mudou na travessia, está marcado.
A regra dos três arquivos — só quando há conteúdo real
Todo componente mora em sua própria pasta:
src/components/common/FornecedorCard/
├── FornecedorCard.tsx o componente
├── FornecedorCard.styles.ts objetos sx / styled — SÓ se houver estilo real
└── index.ts re-export
// src/components/common/FornecedorCard/index.ts
export { default } from "./FornecedorCard";
export type { FornecedorCardProps } from "./FornecedorCard";
Depois atualize o barrel da pasta pai no mesmo commit:
// src/components/common/index.ts
export { default as FornecedorCard } from "./FornecedorCard";
Esquecer o barrel é a falha mais comum — leva a importes inconsistentes (uns via @components, outros
via caminho completo). Ver 03.
Não crie um .styles.ts vazio "por consistência". É comum achar, em código de referência,
componentes com um arquivo de estilo que só contém um comentário e uma função retornando {} — não
porte esse boilerplate.
Se o componente usa só sx inline pontual ou nenhum estilo próprio, ele tem dois arquivos
(.tsx + index.ts), não três. O terceiro arquivo nasce quando há uma constante SxProps<Theme>
real para exportar — ver 12.
Por que arquivo de estilo separado (quando existe)
Mantém o .tsx legível. Um componente com 200 linhas de JSX e 120 de sx inline é ilegível em
revisão.
Onde colocar
O componente é usado por quantas telas?
├─ 1 → src/pages/<Tela>/ (componente local, sem promover)
└─ 2+ ─ é agnóstico de domínio?
├─ sim → src/components/common/
└─ não → src/components/<Nome>/
Compõe o shell da aplicação (barra, menu, abas, moldura de página)? → src/components/layout/.
Não promova por antecipação. Um componente só sai de pages/ quando a segunda tela precisar dele.
Abstrair cedo demais produz props que ninguém usa.
Catálogo de common/
Componentes já portados e verificados. Antes de criar qualquer coisa, verifique se um destes resolve.
Entrada de dados
| Componente | Para que serve |
|---|---|
FormField |
TextField integrado ao react-hook-form, com toggle de senha |
SelectInput |
Select controlado; recebe SelectOption[]
|
DatePickerInput |
Data via <input type="date"> — ver gap abaixo
|
CheckboxGroup |
Grupo de checkboxes; recebe CheckboxOption[]
|
RadioButtonGroup |
Grupo de radios; recebe RadioOption[]
|
MaskedInput |
Base de máscara — CnpjInput/PhoneInput/CurrencyInput se apoiam aqui |
CnpjInput |
CNPJ com máscara e validação (utils/validators/Cnpj.ts) |
PhoneInput |
Telefone com máscara |
CurrencyInput |
Moeda; exporta parseCurrencyValue para converter de volta a número |
Exibição
| Componente | Para que serve |
|---|---|
DataTable |
Tabela com paginação, seleção de linhas e menu de contexto por linha (ContextAction[]) |
ResultadosCard |
Card com título para agrupar resultado de busca |
FiltroBusca |
Bloco de filtro padrão de tela de busca |
TransferListCard |
Lista de transferência (mover itens entre dois lados) |
TransferActions |
Botões de ação do TransferListCard
|
LoadingButton |
Botão com spinner durante ação assíncrona |
ActionModal |
Diálogo de confirmação com ação primária/secundária e estado de loading |
WelcomeCard |
Card de boas-vindas simples (título + descrição) |
PageContainer (título, breadcrumbs, ações, maxWidth) mora em src/components/layout/, não em
common/ — é moldura de página, não peça de formulário/exibição.
DataTableé a fusão de duas referências. É comum encontrar, em bases de código que evoluíram organicamente, uma versão simples e uma versão "avançada" quase idênticas de um mesmo componente de tabela (uma com seleção e menu de contexto, outra sem). Neste padrão, existe um sóDataTable— o superconjunto, comselectable/contextActions/getRowIdopcionais. Quem não precisa dessas props simplesmente não as passa; não há razão para manter os dois.
Componente domínio-neutro fora de
common/. É comum achar um componente estruturalmente genérico (ex.: um modal de confirmação) vivendo fora decommon/só porque nasceu para resolver um caso específico primeiro. Pela própria regra deste guia ("Onde colocar"), ele pertence acommon/— é onde está aqui. Ver anti-padrões em 13.
DatePickerInput não usa o @mui/x-date-pickers
O projeto depende de @mui/x-date-pickers e configura LocalizationProvider + AdapterDateFns em
main.tsx (03) — mas nenhum componente do catálogo
usa o <DatePicker> real da biblioteca. DatePickerInput é só um <input type="date"> (ou
datetime-local/time) por dentro de um TextField, com máscara e validação escritas à mão.
Isso funciona, mas significa que a dependência @mui/x-date-pickers está instalada e configurada
sem nenhum uso de referência. Se a primeira tela real precisar de um seletor de calendário visual
(não só um input nativo do browser), será a primeira vez que alguém usa o <DatePicker> da biblioteca
neste projeto — não existe exemplo para copiar. Escreva-o consultando a documentação do
@mui/x-date-pickers diretamente, e considere promovê-lo a um novo componente de common/ depois.
Assinaturas de referência
FormField — o mais usado:
interface FormFieldProps<T extends FieldValues> extends Omit<TextFieldProps, "name" | "error" | "helperText"> {
name: FieldPath<T>;
control: Control<T>;
label: string;
rules?: ControllerProps<T>["rules"];
showPasswordToggle?: boolean;
}
Estende TextFieldProps, então aceita size, fullWidth, disabled etc. sem redeclarar. error e
helperText são removidos porque vêm do fieldState do react-hook-form.
DataTable:
export interface Column<T = unknown> {
id: string;
label: string;
minWidth?: number;
align?: "right" | "left" | "center";
format?: (value: unknown, row: T) => React.ReactNode;
sortable?: boolean;
}
export interface ContextAction<T = unknown> {
id: string;
label: string;
icon?: React.ReactNode;
onClick: (row: T, index: number) => void;
disabled?: (row: T) => boolean;
divider?: boolean;
}
interface DataTableProps<T = unknown> {
columns: Column<T>[];
data: T[];
loading?: boolean;
error?: string | null;
page?: number;
rowsPerPage?: number;
totalCount?: number;
onPageChange?: (page: number) => void;
onRowsPerPageChange?: (rowsPerPage: number) => void;
onRowClick?: (row: T, index: number) => void;
emptyMessage?: string;
rowsPerPageOptions?: number[];
stickyHeader?: boolean;
maxHeight?: number | string;
// selecao e menu de contexto — opcionais, ignore se nao precisar
selectable?: boolean;
selectedRows?: T[];
onSelectionChange?: (selectedRows: T[]) => void;
getRowId?: (row: T) => string | number;
contextActions?: ContextAction<T>[];
showContextMenu?: boolean;
}
loading, error e emptyMessage são tratados dentro do componente. Não envolva DataTable em
condicional de loading — passe a flag. Sem selectable/contextActions, a tabela se comporta como a
versão simples — as props extras custam zero quando omitidas.
Para os demais, leia a interface no próprio arquivo. Não presuma props a partir do nome.
Como escrever um componente
// src/components/common/FornecedorCard/FornecedorCard.tsx
import React from "react";
import { Card, CardContent, Typography } from "@mui/material";
import { cardStyle } from "./FornecedorCard.styles";
export interface FornecedorCardProps {
nome: string;
cnpj: string;
ativo: boolean;
onSelecionar?: (cnpj: string) => void;
}
const FornecedorCard: React.FC<FornecedorCardProps> = ({ nome, cnpj, ativo, onSelecionar }) => {
const handleClick = () => onSelecionar?.(cnpj);
return (
<Card sx={cardStyle(ativo)} onClick={handleClick}>
<CardContent>
<Typography variant="h6">{nome}</Typography>
<Typography variant="body2" color="text.secondary">
{cnpj}
</Typography>
</CardContent>
</Card>
);
};
export default FornecedorCard;
// src/components/common/FornecedorCard/FornecedorCard.styles.ts
import { SxProps, Theme } from "@mui/material";
// depende do estado "ativo" — por isso funcao, nao constante
export const cardStyle = (ativo: boolean): SxProps<Theme> => ({
opacity: ativo ? 1 : 0.6,
cursor: "pointer"
});
Regras aplicadas:
- Interface de props exportada — outras telas precisam dela para tipar wrappers.
-
React.FC<Props>com props destruturadas na assinatura. - Callback de prop nomeado
on{Ação}; handler internohandle{Ação}— ver 13. -
export defaultdo componente; o barrel dá o nome. - Zero import de
@curio/client,useAuth,useCurioMutation. - Estilo em constante nomeada exportada, não objeto
styles.algo— ver 12.
Componente não decide o próprio posicionamento externo
// ERRADO — o card decide seu proprio tamanho de grid; quem usa nao tem escolha
const WelcomeCard = ({ title, description }) => (
<Grid item xs={12} sm={6}>
<Card>...</Card>
</Grid>
);
// CERTO — o card so sabe renderizar a si mesmo; o layout e responsabilidade de quem chama
const WelcomeCard = ({ title, description }) => <Card>...</Card>;
// quem usa decide o grid:
<Grid item xs={12} sm={6}>
<WelcomeCard title="Bem-vindo!" />
</Grid>;
Um componente reutilizável que embute sua própria posição num grid externo (<Grid item xs={...}>)
só funciona no layout onde foi escrito primeiro. É um erro comum em cards de boas-vindas/destaque; a
versão deste catálogo não faz isso.
Componente não chama backend
// ERRADO — componente comum acoplado a caso de uso
const FornecedorCard = ({ oid }) => {
const { data } = useBuscaFornecedor();
return <Card>{data?.Fornecedor._Nome}</Card>;
};
// CERTO — a pagina busca, o componente exibe
const FornecedorCard = ({ nome, cnpj }: FornecedorCardProps) => <Card>{nome}</Card>;
Exceção: componentes de layout/ podem consumir Context (useAuth, useTabs, useNotification).
São o shell, não peças reutilizáveis.
Genéricos
Componentes que recebem coleção tipada usam genérico com default:
function DataTable<T = unknown>({ columns, data }: DataTableProps<T>) { ... }
Permite <DataTable<FornecedorXML> columns={cols} data={fornecedores} /> com format tipado, e segue
funcionando sem anotação.
O que não copiar de uma implementação existente
Ao investigar código de referência, é comum achar pastas em components/ que parecem genéricas
mas na verdade não pertencem a um catálogo de padrão:
| Sinal | Por quê |
|---|---|
Nome carrega uma entidade concreta de domínio (ex.: Empresa/, FiltroXyzForm/) |
É um formulário/listagem específico daquele domínio, não um padrão |
| Estruturalmente genérico mas usado só numa tela específica (ex.: um card de dashboard) | O valor dele está no acoplamento àquela tela específica, não é reutilizável de verdade |
| Não é um componente de UI, e sim uma tela de demonstração/catálogo manual | Não pertence ao catálogo de componentes reutilizáveis |
Se uma tela nova precisar de algo parecido, escreva-o de novo neste projeto — não é o mesmo esforço de
copiar um componente genuinamente genérico como DataTable, porque o valor de um componente acoplado
está justamente no acoplamento à tela original dele.
Antes de criar um componente
- Existe em
common/? Use. - Existe no MUI? Use o MUI direto — não envolva
Buttonsó para trocar a cor padrão (isso é tema, ver 12). - Só uma tela usa? Deixe em
pages/. - Nenhuma das anteriores? Crie em
common/, com os arquivos que realmente precisar e o barrel atualizado.
No Comments