Pular para conteúdo

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 em apps/web/src/design-system/.

Este arquivo saiu de apps/web/design-system/ para docs/ em 2026-08-01, quando o design system virou pacote git. A spec 0017 desfez o pacote e trouxe a implementação de volta para apps/web/src/design-system/ — a documentação ficou onde está. Onde o texto disser "o pacote", leia o tema em src/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.

  1. type="submit" deixa de funcionar. O PageHeader portala a ação para a barra do app, fora do formulário — o botão continua existindo e não envia nada. Use onClick: () => { void handleSubmit(onSubmit)(); }.
  2. O Enter para de salvar. A submissão implícita do HTML exige um botão submit dentro do <form>; sem nenhum, e com mais de um campo, o Enter não faz nada. Mantenha um botão oculto:
    <button type="submit" aria-hidden="true" tabIndex={-1} style={{ display: "none" }} />
    

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 por slotProps hoje.


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.

  • IconButton com Icon do projeto (components/icon), nunca um ícone cru do MUI — é o glifo consistente com o resto do app.
  • Sem size explícito no Icon. 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) usa size={16}/18 porque compete por espaço com uma coluna a mais — não é o caso comum, e não é o que uma lista nova deveria copiar.
  • Tooltip sempre. O rótulo que o botão de texto carregava vira o title do Tooltip — a informação não desaparece, só para de ocupar a largura da coluna.
  • stopPropagation no onClick, 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". irAoPrimeiroErro lê 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 um Critical da spec 0046, criado pela correção de um Minor. 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 o details[] num catch intermediá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 estado error do Text Field do design system, não um Alert genérico no topo.
  • Erros de servidor por campo (ApiError.details[], ver providers/api-fetch.ts) são mapeados de volta aos campos com setError(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.