Pular para conteúdo

0022 — Paginação no mobile: Inquilinos, Imóveis e Análise de Crédito

Status CONCLUÍDA
Branch spec/0022-paginacao-compacta-no-mobile
Criada em 2026-08-07
Apps afetados web · e2e (api: sem impacto)

Status: RASCUNHO → APROVADA → EM IMPLEMENTAÇÃO → CONCLUÍDA

1. Problema

No mobile, Inquilinos, Imóveis e Análise de Crédito não têm paginação. Têm um botão "Carregar mais" que acrescenta 25 registros por toque, ao fim de uma lista que rola. Numa base de 2.645 imóveis, chegar ao registro 500 custa 19 toques e 500 cartões acumulados na memória do aparelho.

A tela de Imobiliárias tem paginação de verdade — barra fixa no rodapé, com escolha de quantos por página, a faixa atual e as setas de navegação. Duas telas de lista, dois modelos de percorrer o mesmo tipo de dado.

Tela Rodapé no mobile O que faz
Imobiliárias TablePagination fixo, 53px navega páginas, escolhe 25/50/100 por página
Imóveis LoadMore, 69px, rola junto acrescenta 25 ao fim
Inquilinos LoadMore, 69px, rola junto idem
Análise de Crédito LoadMore, 69px, rola junto idem

1.1 O LoadMore ainda custa mais alto do que o que entrega

Além de não paginar, ele mede 69px contra os 53px da barra da imobiliária, e rola junto com a lista — para voltar ao controle de paginação é preciso rolar até o fim de novo. A barra da imobiliária é sticky: está sempre ao alcance do polegar.

Os 69px se decompõem em 8px de padding, 32px de botão, 4px de margem, ~17px de contagem e 8px de padding — o desenho empilha o que caberia numa linha, e em 390px sobram mais de 200px vazios à direita do botão.

1.2 No desktop as quatro paginam, mas não falam a mesma língua

Medido em 1440px, o tamanho já é o mesmo — 52px no DataGrid, 53px na barra da Imobiliárias, fonte de 13px e posição fixa nos dois. Não há altura a corrigir aqui.

O que diverge é o texto:

Imobiliárias (referência) Imóveis · Inquilinos · Análises
Rótulo "Por página:" "Linhas por página:"
Faixa 1–50 de 2357 1–25 de 2.645
Opções 25 · 50 · 100 25 · 50 · 100

E aqui a referência tem um defeito próprio: a Imobiliárias não separa o milhar. "2357" contra "2.645". Ser a tela-modelo não torna certo o que ela faz de errado — o separador é formatação de português, não preferência, e quem se alinha a quem não muda isso.

1.3 No mobile a divergência é de modelo, não de texto

As três telas já mantêm paginationModel e pedem à API com skip/limit no desktop. O mobile é que desvia:

const skip  = isMobile ? 0 : paginationModel.page * paginationModel.pageSize;
const limit = isMobile ? mobileLimit : paginationModel.pageSize;

O estado de paginação já existe; o mobile é que foi desviado para um segundo modelo (mobileLimit). Unificar não acrescenta estado — remove um.

1.4 A barra da imobiliária está copiada em uma tela só, e isso vai piorar

O bloco sticky com TablePagination vive inline em pages/agencies/index.tsx. Replicá-lo como está para mais três telas produz quatro cópias do mesmo rodapé — o padrão que esta sessão corrigiu no FormPanel (spec 0016), no SectionLabel (0019, oito cópias) e no topo de página (0020, cinco cópias do mesmo ternário). Não vale repetir o erro sabendo dele.

2. Objetivo

Que percorrer uma lista longa no celular seja igual em toda tela de lista: barra de paginação fixa no rodapé, com faixa atual, escolha de tamanho e navegação — e que essa barra exista em um lugar no código.

3. Fora de escopo

  • Mudar o tamanho do rodapé no desktop. Os ~52px, a fonte de 13px e a posição fixa são a referência do desktop e ficam como estão (RN8). Lá a padronização é de texto e formatação, não de dimensão.
  • Trocar o DataGrid por outra tabela, ou vice-versa. Cada viewport mantém a implementação que já tem; o que se uniformiza é o que o usuário lê.
  • Migrar as listas de <Table> antigo — Equipe e Perfis de Acesso. Convergir para o padrão de lista do design system é entrega de outra ordem, já registrada como pendência no CLAUDE.md do web. A Imobiliárias entra apenas porque a barra sai de lá para o componente compartilhado; a tabela dela não muda.
  • Rolagem infinita. Foi descartada: o pedido é paginação explícita, e rolagem infinita tem o mesmo defeito do "carregar mais" — não dá como pular para o meio da base.
  • O tamanho de página padrão. Continua o que cada tela já usa.
  • A ordem e os filtros das listas. Não mudam.

4. Regras de negócio

  • RN1 — A tela de Imobiliárias é a referência do rodapé no mobile. Toda lista com paginação usa a mesma barra: fixa no rodapé (sticky), com "Por página", a faixa atual (1–50 de 2357) e as setas de navegação. Inquilinos, Imóveis e Análise de Crédito passam a segui-la; nada do desenho dela muda para acomodá-las.

A direção se inverte no desktop, e é de propósito. Lá a referência é a Análise de Crédito, com DataGrid, e é a Imobiliárias que está atrasada — ela ainda usa <Table> antigo, como o CLAUDE.md do web registra. Uma tela ser modelo num viewport e devedora no outro não é contradição: o que se padroniza é o comportamento, e cada viewport tem a sua melhor resposta já pronta em algum lugar do app.

  • RN2 — A barra vive em um componente compartilhado, não copiada por tela. As quatro telas — Imobiliárias, Imóveis, Inquilinos e Análise de Crédito — consomem a mesma peça. Quatro cópias de um rodapé é exatamente o defeito que as specs 0016, 0019 e 0020 corrigiram em outros componentes.

  • RN3 — O mobile passa a usar o mesmo estado de paginação do desktop. mobileLimit deixa de existir, e skip/limit param de depender do viewport. A troca remove estado; não acrescenta.

  • RN4 — A barra é sticky no rodapé, acima da barra de abas, e não rola com a lista. O controle de paginação tem de estar ao alcance sem rolar até o fim — é a diferença prática entre a barra da imobiliária e o "carregar mais".

  • RN5 — A faixa atual nunca some. "1–50 de 2357" é o que diz onde o usuário está dentro da base, e continua visível mesmo quando só há uma página.

  • RN6 — Trocar de página leva ao topo da lista. Quem vai para a página 2 espera começar a ler do começo dela, não continuar na altura em que estava.

  • RN7 — O LoadMore sai do código. Depois desta spec ele não tem consumidor; deixar componente morto no shared é convite a alguém reintroduzir o modelo antigo.

  • RN12 — A barra tem duas âncoras no mobile e uma no desktop.

Mobile Desktop
"Por página: 25 ⌄" encostado à esquerda à direita, em bloco
"1–50 de 2.357 ‹ ›" encostado à direita à direita, em bloco

No celular a largura é escassa e os dois grupos brigam pelo meio; separá-los nas bordas dá a cada um a sua âncora e deixa o vazio entre eles, não em volta. No desktop sobra largura, e um bloco só à direita mantém a proporção e o alinhamento que a tabela já tem.

Hoje o mobile não faz nem uma coisa nem outra: o espaçador do MUI encolhe para 47px, desloca "Por página" para 55px — nem colado à esquerda nem centrado — e os dois grupos ficam espalhados sem se separarem. No desktop o espaçador mede 861px e já entrega o bloco à direita; lá não há o que mudar.

  • RN8 — No desktop, a padronização é de texto; o tamanho não muda. O rodapé continua medindo ~52px, em fonte de 13px e posição fixa — são os valores de referência do desktop e ficam como estão. O que se uniformiza é o rótulo, as opções e a formatação.

  • RN9 — O rótulo e as opções seguem a Imobiliárias: "Por página:" e 25 · 50 · 100. O DataGrid das outras três passa a dizer o mesmo, por localeText.

  • RN10 — O milhar é separado em toda faixa, inclusive na Imobiliárias. 1–50 de 2.357, não 2357. É o único ponto em que a tela-referência muda: ela adota o que as outras já fazem certo. Referência não é imunidade.

  • RN11 — O tamanho de página padrão continua 25 em todas. A Imobiliárias abre em 50 hoje; passa a 25 como as outras, para que a primeira tela vista seja a mesma em qualquer lista. É o único ajuste de comportamento que a referência sofre, e é o que a torna coerente com o resto.

5. Impacto por app

apps/api

Sem impacto. As três telas já pedem skip/limit; muda quem calcula, não o contrato.

apps/web

Componente novo — components/shared/ListPagination.tsx: o bloco sticky com TablePagination, hoje inline em agencies/index.tsx. Recebe count, page, rowsPerPage, as opções de tamanho e os dois handlers, e publica o data-testid que a página passar.

Páginas:

Arquivo O quê
pages/agencies/index.tsx consome o componente; adota separador de milhar (RN10) e abre em 25 (RN11)
pages/properties/index.tsx LoadMore → ListPagination; sai mobileLimit (RN3)
pages/renters/index.tsx idem
pages/credit-analyses/index.tsx idem

design-system/theme/dataGrid.ts — o localeText do DataGrid passa a dizer "Por página:" (RN9). O separador de milhar já vem do ptBR.

Componente removido — o LoadMore de components/shared/RecordCard.tsx e sua exportação (RN7).

data-testid: preservados. properties-pagination, renters-pagination, credit-analyses-pagination e agencies-pagination continuam no contêiner da barra — mudam de implementação, não de nome.

Responsividade & PWA: a barra só renderiza no ramo mobile; o desktop segue no rodapé do DataGrid. Sem impacto no service worker além do rebuild.

apps/e2e

  • Spec: cenário que afere, em 390px e nas quatro listas, que a barra existe, é sticky, mede ≤ 56px, mostra a faixa atual (RN5) e navega — ir para a página seguinte troca os registros e atualiza a faixa (RN1), e volta ao topo (RN6). Cobre também que "Carregar mais" não existe mais em nenhuma delas (RN7).
  • Seeds: nenhum. As quatro listas têm milhares de registros no seed atual.

6. Contrato de API

Sem alteração. As chamadas continuam GET /<recurso>?skip=&limit=&…; o que muda é o skip deixar de ser fixo em 0 no mobile.

7. Critérios de aceite

  • [x] CA1. Em 390px, as quatro listas têm barra de paginação fixa no rodapé.
  • [x] CA2. A barra mede ≤ 56px — contra os 69px do LoadMore.
  • [x] CA3. A barra é sticky: continua visível ao rolar a lista.
  • [x] CA4. A faixa atual ("1–50 de N") está sempre visível.
  • [x] CA5. Avançar de página troca os registros mostrados e atualiza a faixa.
  • [x] CA6. Trocar de página leva ao topo da lista.
  • [x] CA7. "Carregar mais" não existe em nenhuma lista, e o LoadMore saiu do shared.
  • [x] CA8. A barra vem de um componente: nenhuma página monta TablePagination por conta própria.
  • [x] CA9. No desktop o rodapé continua em ~52px, fonte 13px e posição fixa — os tamanhos de referência não mudaram.
  • [x] CA9a. As quatro listas dizem "Por página:" e oferecem 25 · 50 · 100, nos dois viewports.
  • [x] CA9b. Toda faixa separa o milhar (1–25 de 2.645), inclusive na Imobiliárias.
  • [x] CA9c. As quatro listas abrem em 25 por página.
  • [x] CA10a. Em 390px, "Por página" começa a ≤ 20px da borda esquerda da barra, e as setas terminam a ≤ 20px da direita.
  • [x] CA10b. Em 1440px, os dois grupos continuam à direita, em bloco — o alinhamento de hoje.
  • [x] CA10. Os quatro data-testid de paginação continuam respondendo.
  • [x] CA11. Nenhuma tela rola na horizontal em 375px.
  • [x] CA12. tsc limpo, sem apontamento novo.

8. Plano de implementação

Ordem obrigatória no monorepo: api → web → e2e. Aqui a api não entra.

  • [x] apps/api — sem impacto, nada a fazer
  • [x] apps/web — ListPagination compartilhado, a partir do bloco da Imobiliárias
  • [x] apps/web — agencies/index consome o componente
  • [x] apps/web — as três listas trocam LoadMore por ListPagination e perdem mobileLimit (RN3)
  • [x] apps/web — remover o LoadMore do shared (RN7)
  • [x] docs/ui-patterns.md — a seção de listas ganha a regra do rodapé
  • [x] apps/e2e — spec das quatro listas em 390px
  • [x] make test e tsc
  • [x] /devremote-rebuild + medição nos dois viewports

9. Decisões em aberto

Nenhuma.

Correção de premissa. A primeira redação desta spec supunha que as três telas só precisavam de um rodapé mais baixo, porque medi as três entre si — as encontrei idênticas (69px) e concluí que já estavam padronizadas. Nunca comparei com a Imobiliárias, que é onde está a paginação de verdade. O usuário corrigiu, e a spec foi reescrita: o problema não é altura, é ausência de paginação; a altura menor vem junto, de graça.

Escopo: as três telas nomeadas no pedido original (Inquilinos, Imóveis, Análise de Crédito), mais a Imobiliárias — esta só porque a barra sai de lá para o componente compartilhado.

10. Registro

O que mudou, medido nas quatro listas

Antes Depois
Rodapé no mobile (3 listas) LoadMore, 69px, rola junto barra fixa, 53px
Alinhamento no mobile tudo agrupado a partir de 55px "Por página" a 8px da esquerda, setas a 2px da direita
Rótulo no desktop "Linhas por página" (3) × "Por página" (1) "Por página" nas quatro
Faixa na Imobiliárias 1–50 de 2357 1–25 de 2.357
Padrão por página 50 na Imobiliárias, 25 nas outras 25 nas quatro
Altura no desktop 52px / 53px inalterada
Setas (leitor de tela) "Go to next page" "Próxima página"

O que divergiu do planejado

1. O localeText do DataGrid v9 ignora a chave que a spec supunha.

A RN9 previa uniformizar o rótulo por localeText.MuiTablePagination.labelRowsPerPage — a chave que valia nas versões anteriores do MUI X. Definida, ela não faz efeito e não dá erro: o rodapé seguiu dizendo "Linhas por página" com a configuração aparentemente correta no código. No v9 a paginação é o slot basePagination, e os rótulos entram por slotProps.basePagination.

Só a medição no browser pegou. É o mesmo tipo de armadilha do :not() da spec 0021: configuração que o framework descarta em silêncio — o código diz uma coisa, o pixel diz outra, e nada acusa.

2. As setas estavam em inglês, e a tela-referência também.

"Go to next page" e "Go to previous page", num app em português. Não estava na spec porque ninguém tinha olhado o aria-label — apareceu quando o cenário de navegação precisou clicar no botão e o seletor não casou. Corrigido nas quatro, com getItemAriaLabel.

Vale notar o que o teste fez aqui: ele não foi escrito para achar isso. Precisou apenas de um seletor honesto — pelo nome acessível, não por classe CSS — e o defeito apareceu sozinho.

3. A Imobiliárias mudou em dois pontos, sendo a referência.

Separador de milhar (RN10) e padrão de 25 por página (RN11). Ser tela-modelo não é imunidade: o que ela fazia certo virou padrão, o que fazia errado foi corrigido junto.

4. O seletor de "por página" saía desproporcional — e por dois motivos.

Apontado na validação: o "25" destoava do resto da barra. Medindo, o rótulo e a faixa mediam 13px/20px e o seletor 14px/36px.

Os 36px vinham da regra que faz todo campo de formulário medir o mesmo (MuiSelect { height: 36 }, spec 0016) vazando para um controle que não é campo — mesmo tipo de vazamento que o MuiPaper teve na spec 0021. O piso de 16px do iOS também não se aplica aqui: quem recebe foco no Select do MUI é uma div, e o input nativo fica escondido.

Os 14px vinham por herança do Toolbar, não de regra própria — a classe MuiTablePagination-select fica na raiz do InputBase, e o div interno só herda. Foi preciso rastrear a cadeia de elementos para descobrir; fixar a fonte no seletor sozinho não bastava.

Ambos agora em 13px/19px, iguais ao rótulo e à faixa, nas quatro listas e nos dois viewports.

Vale registrar a distinção que a correção usa: o seletor .MuiTablePagination-select.MuiSelect-select sobe a especificidade de propósito para vencer a regra do tema. Isso é o oposto do erro da spec 0021 apenas por ser local — alcança o que está dentro da barra de paginação, não toda a aplicação. Especificidade alta numa regra global sequestra o sx de todo consumidor; numa regra escopada, é só resolver um conflito conhecido.

5. A barra não ficava no rodapé com lista curta — e o teste dizia que sim.

Apontado na validação. Com um resultado em Inquilinos, a barra ficava 456px acima do rodapé, logo abaixo do único cartão. Nas listas cheias ela parecia certa, e foi por isso que passou.

A causa é estrutural, e é exatamente a diferença que faltava conferir contra a Imobiliárias: lá a barra é irmã do bloco de conteúdo, na raiz da coluna flex, e o flex: 1 do conteúdo a empurra para baixo em qualquer tamanho de lista. Nas três eu a coloquei dentro do bloco de conteúdo, junto dos cartões. position: sticky só prende enquanto há rolagem; sem transbordo, o elemento fica na posição natural — e com 25 cartões sempre havia transbordo, então o defeito só aparecia filtrando.

E o teste afirmava a propriedade, não o efeito. Ele conferia getComputedStyle(bar).position === 'sticky' e passava nas quatro listas enquanto três estavam erradas. sticky estava lá; o que não estava era o resultado.

Trocado por medir a distância entre a base da barra e o fim do contêiner, com um cenário novo de lista curta — que é onde a diferença se manifesta. É a terceira vez nesta sequência de specs que aferir efeito pega o que aferir configuração deixou passar: as sete telas da 0020, o rótulo do DataGrid nesta, e agora isto.

6. E ainda rolava junto com a lista — segunda causa, no mesmo sintoma.

Depois da correção estrutural, a barra continuava saindo do rodapé ao arrastar: aparecia no meio da lista, com cartões acima e abaixo. Medido em três posições de rolagem, a folga era [0, 400, 3834] — ela acompanhava o scroll em vez de ficar.

A raiz das três páginas declara height: "100%" além do minHeight: "100%". Isso trava a caixa na altura da tela enquanto o conteúdo passa muito dela; o contêiner do sticky acaba uma tela abaixo, e depois disso o elemento não tem mais onde se segurar. A Imobiliárias declara só minHeight, e por isso a raiz dela cresce com o conteúdo — 2477px contra os 718px travados das outras.

Passou a height: { xs: "auto", sm: "100%" }: no desktop a altura travada continua, que é o que dá ao DataGrid um limite para rolar por dentro; no mobile a raiz cresce, como na referência.

As duas causas produziam o mesmo sintoma — "a barra não fica no rodapé" — e a primeira correção escondeu metade da segunda: com a barra já como irmã do conteúdo, ela ficava certa no topo da lista e errada a partir do primeiro arraste. O teste media só a posição inicial e passou. Agora mede em três posições de rolagem.

Verificação

  • make test: 206 passaram — nenhum arquivo da API tocado.
  • tsc: limpo.
  • list-pagination.spec.ts: 8/8 em chromium, nos dois viewports e nas quatro listas, incluindo lista curta e três posições de rolagem.
  • Medido no dev-remote após rebuild: os números da tabela acima; sem rolagem horizontal em 390px.
  • Suíte e2e completa: a executar antes do merge.