UI Patterns — Garantora (web)¶
Convenções de implementação dos componentes do
apps/web(Refine + MUI): estrutura de formulário, painéis, tabelas, campos monetários, hierarquia de botões. Complementa o contexto de design e os tokens canônicos. O porquê mora aqui; a implementação mora emapps/web/src/design-system/.Este arquivo saiu de
apps/web/design-system/paradocs/em 2026-08-01, quando o design system virou pacote git. A spec 0017 desfez o pacote e trouxe a implementação de volta paraapps/web/src/design-system/— a documentação ficou onde está. Onde o texto disser "o pacote", leia o tema emsrc/design-system/theme/: é o mesmo objeto, sem repositório à parte.
A grade é de 4pt — sx vale metade do que parece¶
O tema declara spacing: 4 (src/design-system/theme/index.ts). Todo número de espaçamento no sx é multiplicado por 4, não por 8:
| No código | Na tela |
|---|---|
p: 1 | 4px |
p: 2 | 8px |
p: 3 | 12px |
p: 4 | 16px |
gap: 2.5 | 10px |
É o erro mais fácil de cometer neste app, e custou uma spec inteira: a 0016 foi escrita supondo 8pt, e as mudanças que prometiam 12px de respiro entregavam 1px. Antes de escolher um valor, multiplique por 4 e confira se é o pixel que você quer.
Referências que ajudam a não errar: margem de página no mobile é p: { xs: 4 } (16px), padding interno de painel no mobile é p: 2 (8px), e o espaço entre campos empilhados é rowSpacing={2} (8px) — mais os 8px que o próprio campo reserva para o rótulo, conforme a seção a seguir.
AppBar — Título de Página¶
O título de cada página é injetado no AppBar via portal React, não renderizado no body da página.
Mecanismo: - AppHeaderContext (src/contexts/app-header/) expõe dois elementos DOM: titleEl (esquerda) e actionsEl (antes dos controles do usuário). - PageHeader (src/components/shared/PageHeader.tsx) usa createPortal para renderizar no header — não produz DOM no body da página. - Para usar: <PageHeader title="..." subtitle="..." icon={<SeuIcone />} actions={<Button>...</Button>} />
Regras (revistas pela spec 0020): - Toda tela interna usa <PageHeader>, com ícone e título obrigatórios. Lista e detalhe também levam subtítulo; formulário não precisa — "Nova pessoa" não pede glosa. - A ação é do topo, não da página. Passe primaryAction={{ label, icon, onClick, testId }} e, quando houver uma segunda, secondaryAction. O componente decide a forma: botão com rótulo no desktop, botão preenchido de 44×44 no mobile (a secundária, contornada). Até a 0020 cada página resolvia isso com um isMobile ? <IconButton/> : <Button/> copiado — e o IconButton cru era transparente e sem borda, ou seja, um alvo de toque sem nenhum sinal de que era clicável. - Teto de três ações no topo — primaryAction, secondaryAction, tertiaryAction, nesta ordem visual invertida: a terceira é a mais à esquerda, a principal encosta nos controles do usuário, onde o polegar alcança. Da quarta em diante, menu ou barra fixa do rodapé, como faz o detalhe da Análise de Crédito — não porque a largura acabe, mas porque um topo com quatro alvos deixa de ter hierarquia.
O texto anterior parava em duas, dizendo que "três alvos de 44px espremem o título". Medido depois, na agencies/show com as três: em 375px as ações somam 140px e o título fica com 169px, sem rolagem horizontal. O que a fix 0005 tinha medido era outra coisa — três <Button> com rótulo nos dois viewports, 382px de 390px, título em 0px. O problema era a página decidir a forma da ação (RN5), não o número delas.
Nomes longos ainda truncam em 169px, e é o comportamento pretendido: noWrap nas duas linhas, para o topo não crescer e empurrar a lista. - Formulário não tem barra de ações (spec 0024). Salvar e Cancelar vão para as slots do topo, nos dois viewports — a mesma tela mostra a ação no mesmo lugar, esteja a pessoa vendo ou editando.
Duas armadilhas ao mover, ambas do mesmo tipo: o botão sai do <form> e leva junto comportamento que era do HTML, não do React.
type="submit"deixa de funcionar. OPageHeaderportala a ação para a barra do app, fora do formulário — o botão continua existindo e não envia nada. UseonClick: () => { void handleSubmit(onSubmit)(); }.- O Enter para de salvar. A submissão implícita do HTML exige um botão
submitdentro do<form>; sem nenhum, e com mais de um campo, o Enter não faz nada. Mantenha um botão oculto:
A segunda só apareceu porque o e2e a cobria — a primeira versão da spec 0024 afirmava que o onSubmit do <form> bastava. - aria-label sai do label automaticamente: no mobile o rótulo visível some, e o nome acessível é o que resta. - Trilha (breadcrumb) em detalhe e formulário, só no desktop. <módulo> › <título>, com o módulo clicável de volta. É o que o espaço extra do desktop compra — informação, não respiro. No mobile ela roubaria a largura do título, e a volta já está na barra de abas. - Toda página usa <PageHeader> para definir título, ícone e ações contextuais. - O ícone deve ser o mesmo usado no menu lateral (custom-sider) para aquele módulo. - actions é escape hatch para o que não cabe nas três slots — um ToggleButtonGroup, um menu. Botão de ação vai nas slots tipadas, que decidem a forma por viewport. - Formulários usam as slots como qualquer outra tela (spec 0024). O texto anterior dizia que eles "não passam actions — usam o footer fixo próprio"; o footer fixo saiu. - Não passe size no ícone. Quem decide é o PageHeader, pelo breakpoint: 24px dentro de um contêiner de 40×40 no mobile, 20px solto no desktop (spec 0016, RN2–RN4). O contêiner usa surfaceNavActive/navActiveFg — os mesmos tokens da pílula do item ativo da gaveta, para o topo repetir o sinal de "você está aqui". - Ação de ícone no topo do mobile usa TOPBAR_ACTION_SX (alvo de 44px, a medida da gaveta). Exportado junto com o PageHeader.
A barra mede 64px no mobile (TOPBAR_H em components/mobile-shell/) e 56 no desktop. Título e subtítulo são noWrap nos dois: barra mais alta não é licença para o título quebrar em duas linhas e empurrar a lista para fora da tela.
Grid de Painéis — Altura Uniforme¶
Ao usar display: "grid" para dispor múltiplos FormPanel lado a lado, nunca usar alignItems: "start". O padrão CSS Grid (stretch) faz os painéis da mesma linha terem a mesma altura automaticamente.
Para que o Paper interno estique corretamente até a borda inferior da célula, o FormPanel deve ser display: flex, flexDirection: column, com o cabeçalho flexShrink: 0 e o corpo flex: 1.
// Grid de painéis — correto
<Box sx={{
display: "grid",
gridTemplateColumns: { xs: "minmax(0, 1fr)", md: "minmax(0, 1fr) minmax(0, 1fr)" },
gap: { xs: 4, sm: 2.5 },
}}>
<FormPanel title="...">...</FormPanel>
<FormPanel title="...">...</FormPanel>
</Box>
// FormPanel interno — deve ter estrutura flex para esticar
<Paper sx={{ display: "flex", flexDirection: "column", ... }}>
<Box sx={{ flexShrink: 0, ... }}>{/* cabeçalho */}</Box>
<Box sx={{ flex: 1, p: { xs: 2, sm: 3 } }}>{/* conteúdo */}</Box>
</Paper>
Nunca usar alignItems: "start" em grids de painéis — isso quebra a uniformidade de altura entre colunas.
FormPanel — Agrupamento de Campos¶
Campos de formulário são agrupados em painéis com cabeçalho de seção. Não copie o componente para dentro da página — importe o compartilhado:
import { FormPanel } from "../../components/shared";
<FormPanel title="Dados pessoais" description="Opcional." data-testid="renter-dados">
<Grid container spacing={{ xs: 4, sm: 2 }}>
<Grid size={{ xs: 12, sm: 6 }}>…</Grid>
</Grid>
</FormPanel>
Até a spec 0016 havia seis cópias locais quase idênticas, e foi isso que deixou o mobile para trás: a cópia da Análise de Crédito ganhou padding responsivo na spec 0015 e as outras cinco continuaram com p: 3 fixo. A peça vive em components/shared/FormPanel.tsx.
Regras: - Padding é p: { xs: 2, sm: 3 } — 8px no mobile, 12px no desktop (grade de 4pt). O painel já tem borda; com a margem da página em 16px, 8px internos bastam. - Precisa de um estilo próprio no Paper (uma sombra, por exemplo)? Passe sx — ele é fundido. É como Parâmetros mantém a sombra dos seus painéis sem impô-la a todo mundo. - Usar Grid com size={{ xs: 12, sm: N }} dentro dos painéis, e columnSpacing={4} rowSpacing={2} no container — 16px na horizontal, 8px na vertical. Ver Respiro do label logo abaixo: os outros 8px verticais vêm do campo. - Entre painéis, gap: { xs: 5, sm: 4 } (20/16px): painel separa mais que campo. - Todos os campos usam size="small" e medem 36px de altura — inclusive os Select. Se algum aparecer mais baixo, é a regra do MUI (.MuiSelect-select { height: auto; min-height: 1.4375em }) ganhando da altura do tema; a sobrescrita que corrige isso vive em src/design-system/theme/components.ts (MuiSelect), não na página. Ela morou em appOverrides (contexts/color-mode/) entre as specs 0016 e 0017 — em color-mode sobrou só o ícone do caret. - Campos relacionados no mesmo painel, seções distintas em painéis diferentes. - Num grid de painéis, use gridTemplateColumns: minmax(0, 1fr), nunca 1fr: item de grid tem min-width: auto e não encolhe abaixo do conteúdo — foi o que fez Parâmetros rolar 256px na horizontal no mobile (spec 0016, RN15).
Respiro do label — o espaço do rótulo é do rótulo¶
Quando o rótulo sobe (shrink), o MUI aplica translate(14px, -9px): ele passa a ocupar 9px acima da borda superior do campo. Se esse espaço sair do vão do contêiner, basta um gap curto para o rótulo encostar no campo de cima — foi o defeito da spec 0018, com 7px de vão na Análise de Crédito.
A conta, desde a 0018, é 8px reservados pelo campo + 8px de vão do contêiner = 16px de campo a campo:
| Onde | O quê |
|---|---|
| Contexto de formulário | fieldLabelReserveSx — aplicado pelo FormPanel; quem não usa FormPanel aplica no próprio contêiner |
Contêiner Grid | columnSpacing={4} rowSpacing={2} |
Contêiner grid/flex | columnGap como estiver, rowGap: 2 |
| Linha horizontal de campos | fieldRowSx — cancela a reserva |
| Linha que vira coluna no mobile | fieldLabelReserveWhenStackedSx |
Tudo vem de src/design-system/theme/fields.ts.
Por que a reserva é incondicional, e não :has(.MuiInputLabel-shrink): o rótulo sobe quando o campo ganha valor. Reservar só nesse momento faria a tela saltar no primeiro caractere digitado — layout não pode depender de conteúdo.
Por que a reserva não é global no tema: campo com rótulo em barra horizontal (FilterBar de Contratos, Auditoria, Centro de Operações, seletor de tipo do DocumentsPanel) ficaria deslocado em relação aos botões da mesma linha. A reserva vale onde há um campo acima de quem o rótulo possa encostar.
Ao testar, preencha o formulário. Formulário vazio parece certo — o rótulo ainda está deitado dentro da caixa. O defeito só aparece no estado em que o usuário passa a maior parte do tempo.
Páginas — Ocupação do Viewport¶
Toda página deve ocupar a altura total disponível no <main>. O AppLayout define <Box component="main" sx={{ flex: 1, overflow: "auto" }}>, portanto o conteúdo da rota recebe 100% de altura disponível.
Regra obrigatória: toda página usa minHeight: "100%" na raiz junto com display: "flex", flexDirection: "column", garantindo que o sticky footer fique colado ao rodapé mesmo em páginas curtas, e que o conteúdo principal expanda para preencher o espaço vazio.
// Correto — raiz da página
<Box sx={{ display: "flex", flexDirection: "column", minHeight: "100%" }}>
<Box sx={{ flex: 1, p: { xs: 4, sm: 3 } }}>
{/* conteúdo */}
</Box>
{/* sticky footer */}
</Box>
Nunca usar p: { xs: 4, sm: 3 } diretamente na raiz — isso impede que o flex: 1 do conteúdo funcione corretamente.
Formulário — Estrutura Geral¶
<Box component="form" onSubmit={handleSubmit(onSubmit)}
sx={{ display: "flex", flexDirection: "column", minHeight: "100%" }}>
{/* Conteúdo rolável */}
<Box sx={{ flex: 1, p: { xs: 4, sm: 3 } }}>
<PageHeader title="..." icon={<Icone />} />
{error && <Alert severity="error" sx={{ mb: 2.5 }}>{error}</Alert>}
<Box sx={{ display: "flex", flexDirection: "column", gap: { xs: 5, sm: 4 } }}>
<FormPanel title="...">...</FormPanel>
<FormPanel title="...">...</FormPanel>
</Box>
</Box>
{/* Footer fixo */}
<Box sx={{
position: "sticky", bottom: 0, zIndex: 10,
display: "flex", alignItems: "center", justifyContent: "flex-end",
gap: 1.5, px: { xs: 2, sm: 3 }, py: { xs: 3, sm: 1.5 },
bgcolor: "background.paper",
borderTop: "1px solid", borderColor: "divider",
boxShadow: (theme) => `0 -2px 8px ${alpha(theme.palette.primary.main, 0.06)}`,
}}>
<Button variant="text" onClick={() => navigate("/rota")}>
{t("buttons.cancel")}
</Button>
<Button type="submit" variant="contained" disabled={isSubmitting}>
{isSubmitting ? t("common.saving") : t("buttons.save")}
</Button>
</Box>
</Box>
Campos Monetários (BRL)¶
Campos de valor financeiro usam type="text" com formatação pt-BR aplicada no blur e alinhamento à direita.
const parseBRLInput = (v: string): string => {
const stripped = v.replace(/[^\d,.]/g, "");
const normalized = stripped.includes(",")
? stripped.replace(/\./g, "").replace(",", ".")
: stripped;
return normalized;
};
const formatBRL = (raw: string): string => {
const n = parseFloat(raw);
if (isNaN(n)) return "";
return n.toLocaleString("pt-BR", { style: "currency", currency: "BRL" });
};
// Estado: raw (envio à API) + display (exibição)
const [value, setValue] = useState(""); // "1500.50"
const [display, setDisplay] = useState(""); // "R$ 1.500,50"
<TextField
value={display}
onChange={(e) => {
const raw = parseBRLInput(e.target.value);
setValue(raw);
setDisplay(e.target.value.replace(/[^\d,.]/g, ""));
}}
onFocus={() => setDisplay(value)}
onBlur={() => setDisplay(formatBRL(value))}
slotProps={{
htmlInput: { style: { textAlign: "right" } },
inputLabel: { shrink: !!value || undefined },
}}
size="small"
fullWidth
/>
Regras: - Sempre type="text" (nunca type="number" para moeda). - Estado separado: value para o número raw (enviado à API via parseFloat), display para a string formatada. - textAlign: "right" via slotProps.htmlInput.style. - shrink controlado pelo value raw, não pelo display.
Labels MUI + react-hook-form (shrink)¶
Ao usar register() (inputs não controlados), o MUI não sabe quando o campo tem valor e o label não sobe automaticamente no modo edit.
Solução obrigatória em todos os campos de formulário:
const w = watch(); // fora do JSX, no topo do componente
// Em cada TextField:
<TextField
slotProps={{ inputLabel: { shrink: !!w.nome_do_campo || undefined } }}
{...register("nome_do_campo")}
/>
A API antiga (
inputProps,InputProps,InputLabelProps) foi removida até o MUI v9 — a migração dos 101 usos está na spec 0014. Tudo passa porslotPropshoje.
Tints e Superfícies¶
- Cabeçalho de seção (FormPanel, status row, table header):
bgcolor: alpha(theme.palette.secondary.main, 0.05) - Hover de linha de tabela:
alpha(theme.palette.secondary.main, 0.04) - Sombra sutil de paper:
boxShadow: (theme) => \0 1px 4px ${alpha(theme.palette.primary.main, 0.07)}``
Hierarquia de Botões¶
| Contexto | Variante |
|---|---|
| Ação principal da página (CTA) | variant="contained" (primary ou secondary) |
| Ação secundária / alternativa | variant="outlined" |
| Cancelar / voltar | variant="text" |
| Salvar em formulário (footer) | variant="contained" primary |
Filtros das listas — o mesmo recipiente nas quatro¶
Imobiliárias, Inquilinos, Imóveis e Análise de Crédito usam a mesma barra: FilterBar no desktop, MobileFilterBar no mobile (spec 0023). A busca vem primeiro e é o que cresce; filtro de opção é FilterSelect (desktop) / FilterPill (mobile); a contagem fica abaixo da barra, no singular quando for 1.
{isMobile ? (
<MobileFilterBar
count={total} countLabel={total === 1 ? "imobiliária" : "imobiliárias"}
search={search} onSearchChange={setSearch}
data-testid="agencies-filter-bar"
>
<FilterPill label="Status" options={STATUS_OPTIONS} value={statusFilter} … />
</MobileFilterBar>
) : (
<FilterBar count={total} countLabel={…} data-testid="agencies-filter-bar">
<SearchField … data-testid="agencies-search-input" />
<FilterSelect label="Status" options={STATUS_OPTIONS} … data-testid="agencies-status-filter" />
</FilterBar>
)}
Nada de ToggleButtonGroup como filtro. Ele cresce com o número de opções e não cabe em 390px além de três; o FilterSelect mostra o rótulo quando vazio e o valor quando escolhido, com largura constante.
Filtro filtra no servidor — sempre. Filtro client-side sobre lista paginada não é filtro: é um recorte do acaso. A Imobiliárias fazia agencies.filter((a) => a.status === statusFilter) sobre a página carregada; com 2.357 parceiras em páginas de 25, escolher "Inativa" varria 25 registros e o rodapé seguia dizendo 1–25 de 2.357 — sem como distinguir "não há inativas" de "não há inativas nesta página". O mesmo defeito já estava registrado no properties/index.tsx, para o filtro de tipo. Quando a API não aceita o filtro, o trabalho é acrescentá-lo lá (?status= nasceu assim), não filtrar na tela.
Trocar filtro ou busca volta à página 0. Um useEffect sobre as dependências de filtro, não uma chamada espalhada por cada onChange:
useEffect(() => {
setPaginationModel((m) => ({ ...m, page: 0 }));
}, [debouncedSearch, statusFilter]);
O data-testid da barra é <recurso>-filter-bar, o da busca <recurso>-search-input e o de cada filtro <recurso>-<filtro>-filter — nos dois viewports, com o mesmo nome.
Tabelas — Padrão¶
Ações de linha são ícone, nunca texto (achado na spec 0048: a tela de Cobranças nasceu com <Button>Registrar pagamento</Button>/<Button>Marcar inadimplente</Button>, única lista do app com botão de texto na coluna de Ações — todas as outras, de Imobiliárias a Garantias, usam IconButton). Texto na célula de ações alarga a coluna de forma imprevisível conforme o rótulo, e quebra a leitura em coluna que o resto da tabela sustenta.
IconButtoncomIcondo projeto (components/icon), nunca um ícone cru do MUI — é o glifo consistente com o resto do app.- Sem
sizeexplícito noIcon. O padrão (Imobiliárias,agencies/index.tsx) é deixar o default de 20px; só a lista embutida dentro de outra tela (cobrança dentro do detalhe da garantia) usasize={16}/18porque compete por espaço com uma coluna a mais — não é o caso comum, e não é o que uma lista nova deveria copiar. Tooltipsempre. O rótulo que o botão de texto carregava vira otitledoTooltip— a informação não desaparece, só para de ocupar a largura da coluna.stopPropagationnoonClick, se a linha inteira também navega ao ser clicada — senão o clique no ícone dispara os dois.
<Paper elevation={0} sx={{ border: "1px solid", borderColor: "divider", borderRadius: 2, overflow: "hidden" }}>
<Table size="small">
<TableHead>
<TableRow sx={{ bgcolor: (theme) => alpha(theme.palette.secondary.main, 0.05) }}>
<TableCell sx={tableHeaderCellSx}>...</TableCell>
</TableRow>
</TableHead>
<TableBody>
{items.map((item, idx) => (
<TableRow key={item.id} hover
sx={{
cursor: "pointer",
"&:hover": { bgcolor: (theme) => alpha(theme.palette.secondary.main, 0.04) },
...(idx === items.length - 1 ? { "&:last-child td": { borderBottom: 0 } } : {}),
}}
onClick={() => navigate(`/rota/${item.id}`)}>
...
<TableCell align="right" onClick={(e) => e.stopPropagation()}>
<Tooltip title="Ver">
<IconButton size="small" onClick={() => navigate(`/rota/${item.id}`)}>
<Icon name="eye" />
</IconButton>
</Tooltip>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Paper>
Página de Detalhe (Show) — Padrão¶
<Paper elevation={0} sx={{ border: "1px solid", borderColor: "divider", borderRadius: 2, overflow: "hidden",
boxShadow: (theme) => `0 1px 4px ${alpha(theme.palette.primary.main, 0.07)}` }}>
{/* Status / meta row */}
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between",
px: 3, py: 1.5, bgcolor: (theme) => alpha(theme.palette.secondary.main, 0.05),
borderBottom: "1px solid", borderColor: "divider" }}>
<StatusBadge status={item.status} map={STATUS_MAP} />
<Typography variant="caption" color="text.secondary">...</Typography>
</Box>
<Box sx={{ p: 3 }}>
<SectionTitle>Seção</SectionTitle>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr 1fr" }, gap: 2.5, mb: 3 }}>
<Field label="..." value={item.campo} />
</Box>
<Divider sx={{ mb: 2.5 }} />
...
</Box>
</Paper>
Componentes auxiliares de detalhe:
function Field({ label, value }: { label: string; value?: string | null }) {
if (!value) return null;
return (
<Box>
<Typography variant="caption" color="text.secondary" sx={{ display: "block", mb: 0.25 }}>{label}</Typography>
<Typography variant="body2" fontWeight={500}>{value}</Typography>
</Box>
);
}
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<Typography variant="overline" sx={{ display: "block", mb: 2, fontSize: "0.68rem",
letterSpacing: "0.09em", color: "secondary.main", opacity: 0.85 }}>
{children}
</Typography>
);
}
Feedback — roteamento e validação¶
Cada situação tem um componente certo — não usar modal para tudo:
| Situação | Componente | Comportamento |
|---|---|---|
| Sucesso / info | Toast | não-bloqueante, auto-dismiss (~4–5s), canto inferior |
| Ação que exige confirmação | Modal Confirmação | Cancelar / Continuar |
| Falha de operação | Modal Erro | detalhes técnicos + Copiar para o suporte |
| Validação de formulário | inline no campo + scroll ao 1º erro | nunca toast/modal |
| Aviso persistente no contexto | Alert | fica na página, inline |
Validação de formulário — scroll automático ao 1º erro. No submit inválido, focar (e rolar até) o primeiro campo com erro, em vez de mostrar um resumo no topo ou um modal. Use irAoPrimeiroErro, de shared/utils/foco-no-erro.ts:
const formRef = React.useRef<HTMLFormElement>(null);
<Box
component="form"
ref={formRef}
onSubmit={handleSubmit(onSubmit, () => irAoPrimeiroErro(formRef.current))}
>
"Primeiro" é o primeiro da tela, não o primeiro do objeto de erros (spec 0046, RN12). A receita anterior aqui era
Object.keys(errs)[0]+setFocus, e ela ordena pela ordem de registro dos campos — que num formulário longo quase nunca coincide com a ordem visual. A pessoa era levada ao meio da tela enquanto o primeiro erro visível ficava acima, fora da vista; uma rolagem que para no lugar errado é pior que rolagem nenhuma, porque ela afirma "é aqui".irAoPrimeiroErrolê a ordem do DOM: de cima para baixo, desempatando pela esquerda dentro da mesma linha da grade.Ele mira o campo e o texto abaixo dele — a explicação é o que a pessoa foi ler, e parar com o campo colado no limite da área visível a deixa fora da tela. Em 375px a área visível é a do
visualViewport, não a da janela: o teclado virtual cobre até metade da altura.
A recusa da API entra na mesma conta. Um 422 que nomeia campos é erro de campo como qualquer outro:
const tratada = aplicarErrosDaApi<FormData>(e, setFieldError, formRef.current, (sobra) => {
setDetails(sobra); // resumo no topo
setError(sobra.length > 0 ? (e as Error).message : null);
});
if (!tratada) setError(e instanceof Error ? e.message : "Tentar novamente");
Os dois últimos argumentos não são opcionais, e cada um evita um defeito que já aconteceu. O elemento do formulário é por onde a função descobre quais campos existem: erro posto em nome que não tem <input> na tela — um Autocomplete com estado próprio, por exemplo — fica preso no react-hook-form e faz o Salvar deixar de gravar sem nada vermelho aparecer. O callback recebe o que não apareceu na tela, medido depois do repintar pelo aria-invalid; é o resumo do topo, e a faixa deve sumir quando ele vier vazio.
Por que "não apareceu" e não "não está registrado". Registrado não quer dizer visível: num formulário típico daqui, treze campos são registrados e um liga
error/helperText. Mandar para a faixa só o não-registrado fazia a recusa em qualquer um dos outros doze sumir inteira — sem marca, sem texto e sem rolagem, com a pessoa clicando em Salvar e a tela não fazendo nada. Foi umCriticalda spec 0046, criado pela correção de umMinor. O que não entra é a faixa do topo dizendo "Verifique os detalhes abaixo" sem detalhe abaixo — foi o defeito que originou a spec 0046, e ele nasce exatamente de descartar odetails[]numcatchintermediário.
A exceção, e é uma só: o erro que não está montado. Em formulário de várias etapas, os campos da etapa que não está na tela não existem no DOM — não há para onde rolar, e a rolagem falha em silêncio. Aí, e só aí, abre-se um diálogo listando os campos com o rótulo em português, cada linha levando de volta à etapa deles (spec 0046, RN14). O sinal é o retorno de irAoPrimeiroErro: false quer dizer "não achei campo com erro na tela". Formulário de uma etapa nunca cai nesse caminho.
- O erro do campo aparece no próprio
TextField(error+helperText) — é o estadoerrordo Text Field do design system, não um Alert genérico no topo. - Erros de servidor por campo (
ApiError.details[], verproviders/api-fetch.ts) são mapeados de volta aos campos comsetError(field, { message })— mesmo tratamento inline, com o scroll indo ao primeiro. - Só use um Alert no topo do form para erro não atribuível a um campo (ex.: falha de rede) — aí sim inline e persistente, não modal.