From 00a4b7f0c7efe20cdcdaf2064b79942fdf7bf8aa Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Mon, 10 Aug 2026 09:11:38 -0300 Subject: [PATCH] fix(D7): sticky dos headers das tabelas desktop volta a engajar (overflow-x-clip) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O wrapper do ui/Table (`overflow-x-auto`, depois `overflow-x-hidden` via container queries) é um scroll container nos dois eixos — o overflow-y computa para `auto` — e o th `position: sticky; top: 0` declarado pelo B41 resolvia contra ele, que nunca rola (o scroll vertical real é o CampaignContentScroll da shell). A feature estava silenciosamente morta desde o B41. `overflow-x: clip` (permitido ao lado de `overflow-y: visible`, sem promover visible→auto) NÃO cria scroll container: o th volta a resolver contra o scrollport da shell e engaja. Troca nas duas listas com sticky declarado (MunicipalityList, TerritoryList), preservando o fallback `overflow-x-auto` para browsers sem container queries. As demais listas (lideranças, demandas, dobradinhas, apoiadores) estouram em 1440px e dependem do scroller — não tocadas. Validado nos 3 engines (Chromium/Firefox/WebKit): th 174 → 69 (topo do content box do scrollport, padding da shell acima — mesmo ponto onde o omnibox mobile do B184 cola). E2E novo pina o engajamento com asserção bilateral + guarda de computed style; unit pinado no novo contrato; JSDoc do containerClassName atualizado. Closes #543. --- .../sticky-tabela-desktop-quebrado-impl.md | 133 ++++++++++++++++++ .../municipality/MunicipalityList.tsx | 2 +- .../campaign/municipality/TerritoryList.tsx | 2 +- src/components/ui/Table.tsx | 7 +- .../e2e/campaignTableStickyHeader.e2e.spec.ts | 96 +++++++++++++ tests/unit/campaignComponents.unit.spec.ts | 11 +- 6 files changed, 244 insertions(+), 7 deletions(-) create mode 100644 docs/plans/sticky-tabela-desktop-quebrado-impl.md create mode 100644 tests/e2e/campaignTableStickyHeader.e2e.spec.ts diff --git a/docs/plans/sticky-tabela-desktop-quebrado-impl.md b/docs/plans/sticky-tabela-desktop-quebrado-impl.md new file mode 100644 index 00000000..a39590ad --- /dev/null +++ b/docs/plans/sticky-tabela-desktop-quebrado-impl.md @@ -0,0 +1,133 @@ +# Impl: D7 — Sticky dos headers da tabela desktop quebrado (headers não grudam ao rolar) + +Status: aprovado +Atualizado em: 2026-08-10 +Issue: #543 +Intenção: docs/plans/sticky-tabela-desktop-quebrado.md +Appetite restante: herdado (P3, correção localizada — sem migration, sem dados, sem access) + +## Leitura da intenção + +- **Outcome:** em desktop, os headers das tabelas (`th`) grudam ao rolar a página — a feature + declarada pelo B41 (hoje CSS morto) volta a funcionar em `/campanha/municipios` e + `/campanha/territorios` (as listas com `headerClassName` sticky declarado). +- **O que NÃO negociar:** fora de escopo mudar o padrão de scroll da página + (`CampaignContentScroll`); não reescrever o layout das listas para "inner scroll"; manter o + scroll horizontal onde ele é necessário (as demais listas estouram e NÃO têm sticky declarado). +- **O que reavaliar:** a hipótese "A/B/C" da intenção. Medido no browser (prod build local + + dev server do worktree), a opção viável em CSS puro é a variante de A que a intenção não + considerou: `overflow-x: clip` combinado com `overflow-y: visible` **não cria scroll + container** (regra da spec: `clip` pode coexistir com `visible`, diferente de `auto`/`hidden`), + então o `th` sticky resolve contra o scrollport da shell e engaja — sem perder o scroll + horizontal nas tabelas que cabem (medido: nenhuma das 2 listas estoura). + +## Abordagem recomendada + +```mermaid +flowchart LR + W[wrapper ui/Table
overflow-x: clip + overflow-y: visible
NÃO é scroll container] --> H[th sticky top-0] + H --> S[CampaignContentScroll
scroll vertical real] + S --> F[Fallback sem container queries:
overflow-x: auto atual] +``` + +**Opções consideradas:** A (clip puro) | A′ (clip + fallback container-queries) | B (inner +scroll) | C (remover classes mortas) | D (espelho/JS pinning) + +**Recomendação: A′** — trocar `containerClassName` nas 2 listas que declaram sticky: +`overflow-x-auto supports-[container-type:inline-size]:overflow-x-hidden` → +`overflow-x-auto supports-[container-type:inline-size]:overflow-x-clip`. Browsers com container +queries (todos os modernos) ganham `overflow-x: clip` — a regra da spec permite `clip` ao lado +de `overflow-y: visible` (que permanece `visible` sem classe explícita, pois `clip` não promove +`visible→auto`, diferente de `auto`/`hidden`), e o container deixa de ser scroll container: o +`[&_th]:sticky [&_th]:top-0` já declarado engaja contra o `CampaignContentScroll` (validado +empiricamente nos 3 engines: top do th 174 → 69 ao rolar 500px — 69 = topo do content box do +scrollport, com o padding `p-6` da shell acima do header; o omnibox mobile do B184 cola no +mesmo ponto). Browsers sem container queries mantêm o `overflow-x-auto` atual (fallback +intocado — mesma política do `a36b0b26`). + +**Rejeitadas:** + +- **A (clip em todas as larguras, sem fallback):** browsers sem container queries perderiam o + scroll horizontal que o fallback atual garante. +- **B (inner scroll — `max-h` + `overflow-y-auto` no wrapper):** muda a UX de scroll da página + (a tabela vira data-grid com scroll próprio) — o rabbit hole que a intenção mandou parar e + propor antes; não é o aceite ("gruda ao rolar a página"). +- **C (remover as classes mortas):** honesto, mas não entrega o aceite de produto. +- **D (header espelho / JS pinning):** alto custo e frágil com o sistema atual de headers + interativos (`MunicipalitySortableHead`, filtros, tooltips — islands client duplicadas + significariam popovers/aria-sort duplicados); o próprio plano B161 deliberou contra. + +### Componentes / mudanças + +- **`MunicipalityList.tsx:737`** (`containerClassName`): `overflow-x-auto +supports-[container-type:inline-size]:overflow-x-hidden` → + `overflow-x-auto supports-[container-type:inline-size]:overflow-x-clip`. +- **`TerritoryList.tsx:79`** (idem). +- **`headerClassName`**: nenhuma mudança — `[&_th]:sticky [&_th]:top-0 [&_th]:z-10 +[&_th]:bg-background [&_th]:shadow-[inset_0_-1px_0_var(--border)] …` já está correto (o + `z-10` do `[&_th]:` vence o `z-20` do th do nome por especificidade e cobre as células + sticky-left `z-[5]`). +- **`CampaignTable` / `ui/Table` / shell**: zero mudanças — outras listas dependem do default + `overflow-x-auto` (medido: estouram em 1440px — lideranças +588px, dobradinhas +244px, + demandas +145px, apoiadores +57px — e NÃO declaram sticky; clip nelas cortaria colunas). +- **Migration / Access / Consent:** nenhum. +- **UI:** Impeccable A (fix de CSS localizado; sem shape/craft novos). + +### Evidência medida (worktree `teqo_wt7`, dev server local, coordinator) + +| Lista | 1440px | 1280px | 1024px | Sticky declarado | +| ------------------------------------------ | ------------------------ | ---------------------- | --------------------- | ---------------- | +| municipios | sem overflow (1134=1134) | sem overflow (974=974) | tabela oculta (cards) | sim | +| territorios | sem overflow | sem overflow | sem overflow | sim | +| liderancas/demandas/dobradinhas/apoiadores | **estouram** | — | — | não | + +Protótipo aplicado via devtools (`overflow-x: clip` + `overflow-y: visible`): th engaja — +top 174 → 69 (topo do content box do scrollport, padding da shell acima) — validado em +Chromium 1208/1217, Firefox 1511 e WebKit 2272 (Playwright 1.58.2; o E2E do repo roda só +Chromium). O stick-left da coluna Município não muda (resolve contra o scrollport, que não rola +horizontalmente — já era assim). O scroll de verificação do protótipo foi 500px; o E2E usa um +alvo computado (`topBefore − pinnedTop + 200`) para não depender da altura da página. + +## Fases verificáveis + +1. **Mudança de CSS** — 2 linhas (`MunicipalityList` + `TerritoryList`) + JSDoc de + `containerClassName` em `ui/Table` (documenta o padrão clip como o caminho sticky). +2. **Testes** — `tests/e2e/campaignTableStickyHeader.e2e.spec.ts` (Chromium, padrão do projeto + `campaign`): guarda de `overflow-x: clip` computado + engajamento bilateral (o th fica entre + o topo do scrollport e a borda do content box ao rolar, e volta ao fluxo ao zerar o scroll); + `tests/unit/campaignComponents.unit.spec.ts` atualizado para pinar a string + `supports-[container-type:inline-size]:overflow-x-clip`. +3. **Gates** — `pnpm gate:fast` na iteração; entrega com `pnpm push` (CI roda unit + int + e2e + + lint + typecheck + knip + cycles + prettier + migration-lock). + +## Rabbit holes / Não escopo (engenharia) + +- Estender sticky às outras 4 listas: **fora de escopo** — elas estouram em 1440 e precisariam + de colunas responsivas no padrão B158 (esconder) antes de trocar para clip. Gatilho registrado: + se o produto pedir sticky nelas, é uma Issue própria com essa pré-condição. +- Adotar/mergear a branch `agent/B161-listas-scroll-infinito` (virtualização + sticky com + `--campaign-list-controls-height`): escopo diferente (5 listas, scroll infinito); a D7 é o fix + cirúrgico no main. O B161 reutiliza a mesma primitiva CSS (`overflow-x-clip`), sem conflito. +- `ui/Table` default: não muda (o JSDoc de `containerClassName` já documenta o caso sticky). +- Verificação WebKit/Firefox em CI: o E2E do repo roda só Chromium; a validação FF/WebKit foi + feita manualmente nesta sessão (evidência no impl plan) — não cria infra nova. + +## Riscos e mitigação + +- **Browsers sem container queries** mantêm o comportamento atual (sticky inerte + scroll + horizontal) — degradação aceita, mesmo padrão do `a36b0b26`. +- **Clip cortar conteúdo em algum tamanho não medido** (ex. viewport 1440 com Sollinha aberta + encolhendo o conteúdo): mitiga-se re-medindo em execução; se alguma largura estourar, esconder + colunas por container query no padrão B158 (nunca reintroduzir scroller — decisão do B161). +- **Sticky visual sobre linhas hover** (`hover:bg-muted/50`): o th tem `bg-background` sólido + + `z-10` — cobertura já declarada no `headerClassName`. + +## Aceite de engenharia + +- [x] Aceite de produto da intenção ainda coberto (sticky engaja nas 2 listas; scroll da página + preservado; nada de inner scroll) +- [x] Invariantes AGENTS/engineering-standards (sem migration, sem access, sem Consent; código + pt-BR nas strings já existentes) +- [x] Testes previstos: E2E de engajamento do sticky (novo spec, Chromium); suíte existente de + colunas responsivas (`campaignMunicipalityResponsiveColumns`) continua cobrindo + `expectNoHorizontalOverflow` diff --git a/src/components/campaign/municipality/MunicipalityList.tsx b/src/components/campaign/municipality/MunicipalityList.tsx index e016fdf0..d8bc2ca2 100644 --- a/src/components/campaign/municipality/MunicipalityList.tsx +++ b/src/components/campaign/municipality/MunicipalityList.tsx @@ -734,7 +734,7 @@ export const MunicipalityList = (props: MunicipalityListProps) => { {/* A sticky can't paint the row border, hence the inset shadow. */} diff --git a/src/components/campaign/municipality/TerritoryList.tsx b/src/components/campaign/municipality/TerritoryList.tsx index a6c2ecb4..f41120a8 100644 --- a/src/components/campaign/municipality/TerritoryList.tsx +++ b/src/components/campaign/municipality/TerritoryList.tsx @@ -76,7 +76,7 @@ export const TerritoryList = ({ & { /** Override the scroll container (default already includes `overflow-x-auto`). - * Sticky-left columns keep that default; `overflow-x-visible` only when a - * sticky header must resolve against the page scroller instead of this box. */ + * Sticky-left columns keep that default. A sticky header must resolve + * against the page scroller instead of this box, which requires the box NOT + * to be a scroll container at all — use `overflow-x-clip` (clip pairs with + * `overflow-y: visible` without promoting it to `auto`), as the sticky + * campaign lists (D7) do. Never use `overflow-x-visible` here. */ containerClassName?: string }) => (
` set resolved against the `ui/Table` wrapper, which + * `overflow-x-auto` (and the later `overflow-x-hidden` fallback) makes a scroll + * container in both axes; the real vertical scroll happens on the shell's + * `CampaignContentScroll`, so the header never engaged. The fix makes the + * wrapper `overflow-x-clip` (clip + `overflow-y: visible` does NOT create a + * scroll container), letting the already-declared `[&_th]:sticky` resolve + * against the page scroller. This spec pins the behavior: the computed-style + * guard below catches a regression to any scroll-container value, and the + * two-sided position assertion catches the engagement itself. + */ +const contentScroll = (page: Page) => page.locator('[data-slot="campaign-content-scroll"]') +// During a concurrent RSC navigation Next may briefly retain the previous +// segment beside the incoming one — the current segment is appended last +// (same convention as campaignMunicipalityResponsiveColumns). Scoped to the +// list's own container so a second table on the page is never mistaken for it. +const tableContainer = (page: Page) => + page + .locator('[data-container="municipality-list"], [data-container="territory-list"]') + .last() + .locator('[data-slot="table-container"]') +const firstTableHeader = (page: Page) => tableContainer(page).locator('thead th').first() + +const topOf = (locator: Locator) => + locator.evaluate((element) => element.getBoundingClientRect().top) + +/** + * A sticky `top: 0` pins against the scrollport's content box, not its border: + * the scroll container's own padding stays glued to the top of the viewport, + * so the header rests below it (the B184 mobile omnibox behaves the same way). + */ +const contentAreaTop = (locator: Locator) => + locator.evaluate((element) => { + const style = getComputedStyle(element) + return element.getBoundingClientRect().top + parseFloat(style.paddingTop) + }) + +test.describe('D7 — sticky header das tabelas desktop', () => { + test.setTimeout(90_000) + + for (const route of ['municipios', 'territorios']) { + test(`${route}: o thead gruda no topo do scrollport e volta ao fluxo`, async ({ + campaign, + page, + }) => { + const coordinator = await campaign.fixtures.createCampaignUser('coordinator') + await campaign.login(page, coordinator.email!, coordinator.password) + + // 1600x900 keeps the real desktop rails (open sidebar, Sollinha) far + // above the 48rem container threshold, so the table is never gated + // closed by the responsive columns (same convention as the rails test + // in campaignMunicipalityResponsiveColumns). + await page.setViewportSize({ width: 1600, height: 900 }) + await page.goto(`/campanha/${route}`) + await page.waitForLoadState('networkidle') + + const scrollport = contentScroll(page) + const header = firstTableHeader(page) + await expect(header).toBeVisible() + + // Guard: the wrapper must not be a scroll container — an auto/hidden + // value keeps the sticky resolved against itself, where it never engages. + await expect + .poll(() => tableContainer(page).evaluate((element) => getComputedStyle(element).overflowX)) + .toBe('clip') + + const scrollportTop = await topOf(scrollport) + const pinnedTop = await contentAreaTop(scrollport) + const topBefore = await topOf(header) + // The header sits below the pinned line while in the flow (the filters + // region sits above the table) — without that gap there is nothing to pin. + expect(topBefore).toBeGreaterThan(pinnedTop + 30) + + // Scroll far enough that a non-sticky header would leave the scrollport + // entirely, while a pinned one stays between the scrollport top and the + // content-area top (its own padding included). + await scrollport.evaluate( + (element, offset) => { + element.scrollTop = offset + }, + topBefore - pinnedTop + 200, + ) + await expect.poll(() => topOf(header)).toBeLessThanOrEqual(pinnedTop + 2) + await expect.poll(() => topOf(header)).toBeGreaterThanOrEqual(scrollportTop - 1) + + await scrollport.evaluate((element) => { + element.scrollTop = 0 + }) + await expect.poll(() => topOf(header)).toBeGreaterThan(pinnedTop + 30) + }) + } +}) diff --git a/tests/unit/campaignComponents.unit.spec.ts b/tests/unit/campaignComponents.unit.spec.ts index 60883fe6..9294f659 100644 --- a/tests/unit/campaignComponents.unit.spec.ts +++ b/tests/unit/campaignComponents.unit.spec.ts @@ -323,14 +323,19 @@ describe('campaign visual foundation', () => { // B158: the table is container-driven and clips instead of delegating to a // horizontal scroller. Município remains pinned and owns the territory subline. + // D7: `clip` (paired with `overflow-y: visible`) creates NO scroll container, + // which is what lets the declared sticky `` resolve against the shell + // scroller and actually engage. expect(html).toContain('data-container="municipality-list"') expect(html).toContain('@container/municipality-list') expect(html).toContain('@min-[48rem]/municipality-list:block') expect(html).toContain('@min-[48rem]/municipality-list:hidden') expect(html).toContain('data-slot="table-container"') - expect(html).toContain('supports-[container-type:inline-size]:overflow-x-hidden') - // overflow-x-auto is the fallback for browsers without container queries; - // the test above still proves the container-query path takes precedence. + expect(html).toContain('supports-[container-type:inline-size]:overflow-x-clip') + // overflow-x-auto remains the fallback for browsers without container + // queries; which branch actually wins at runtime is pinned by the E2E + // computed-style guard (campaignTableStickyHeader) and by + // expectNoHorizontalOverflow in the responsive-columns spec. expect(html).toContain('overflow-x-auto') expect(theadHtml).toContain('Município') expect(tbodyHtml).toContain('Seabra')