B41: horizontal scroll and sticky Município on the municipalities list.

Mirror TerritoryList so wide staff columns can scroll on md+ without losing the row identity.
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-27 10:14:54 -03:00
parent 22135c0a31
commit 6c0c5e87ce
9 changed files with 65 additions and 26 deletions

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,28 @@
# Critique — MunicipalityList B41 (2026-07-27)
Provenance: Assessment A + B via isolated subagents. Browser skipped (no local server). Detector exit 0, 0 findings.
## AI slop
No — Territory mirror with domain comment.
## Strengths
- Sticky Município keeps orientation while scrolling staff columns
- Desktop/mobile split preserved (cards = B42)
- Chrome matches Territory (overflow shell, z-index corner)
## Issues addressed this session
- **P1 fixed:** removed conflicting `max-w-52` with `min-w-56` on sticky name cell (aligned to Territory `min-w-56` only)
- **P3 fixed:** E10 comment no longer claims the table cannot scroll horizontally
## Deferred (plan rabbit holes / adiado)
- **P1 edge fade:** plan Adiado — shadow/edge fade indicating more columns; revisit if field reports discoverability miss
- **P2 row-hover tint on sticky cell:** Territory parent rows same solid `bg-background`; skip until 3rd sticky call site
- **P2 denser sticky content (badge wrap):** accepted for v1; B17 column picker mitigates
## Harden/optimize
Out (no form/action/empty; no perf signal)

View file

@ -1,12 +1,16 @@
# Scroll horizontal + coluna Município fixa na lista de municípios
Status: rascunho
Atualizado em: 2026-07-26
Status: entregue
Atualizado em: 2026-07-27
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B, item B41)
Impeccable: B — encaixe em `CampaignTable` / `MunicipalityList` (desktop/tablet); sem rota nova
Appetite: ~0,5–0,75 dia eng; overflow-x + sticky left na 1ª coluna; reusa padrão de `TerritoryList`
Responsável: —
**Revisão 2026-07-26 (auditoria pré-implementação):** questão aberta fechada — **A** (classes na column def `name` só; sem `stickyColumnId` no `CampaignTable` até o 3º call site). O wrapper desktop já usa `className="hidden overflow-visible md:block"` (sobrescreve o `overflow-hidden` do `CampaignTable`, como Territory) — **manter** ao trocar só `containerClassName` para `overflow-x-auto`.
**Revisão 2026-07-27 (as-built):** `MunicipalityList` → `containerClassName="overflow-x-auto"`; coluna `name` com `sticky left-0 z-20` (head) / `z-[5]` (cell) + `min-w-56 bg-background` (espelho Territory; `max-w-52` removido no polish — conflitava com `min-w-56`). Mobile cards intactos. Critique: 0 P0; P1 width fix aplicado; edge fade permanece Adiado; sticky top+left layering igual Territory (header pode grudar no scroller interno do `overflow-x-auto` — aceitável). Gate: tsc/lint/format/knip(P3 pré-existente)/cycles/540 unit/413 int/build; Aikido 0 findings (Opengrep exit 2 conhecido).
## Design (Impeccable)
Âncoras: `PRODUCT.md` (Clarity under pressure; anti spreadsheet) / `DESIGN.md` · sistema de listas Pass 2 W1 · precedente **B21 ✓** (`TerritoryList`).
@ -50,7 +54,7 @@ O precedente já existe na página de territórios (**B21 ✓**): [`TerritoryLis
## Questões em aberto
- **Extrair helper de classes sticky para `CampaignTable`?** **Opções:** A) só classes nas column defs de municípios (e Territory já tem as suas) | B) prop `stickyColumnId` no `CampaignTable`. **Recomendação:** A no v1 (<3 call sites distintos de API); B quando B29/outras listas pedirem o mesmo seam. _(assumido.)_
- **Extrair helper de classes sticky para `CampaignTable`?** **Resolvida (2026-07-26):** **A** — só classes nas column defs de municípios (Territory já tem as suas). **B** (`stickyColumnId` no `CampaignTable`) quando o 3º call site pedir o mesmo seam. _(assumido — validado na auditoria de implementação.)_
## Abordagem proposta

File diff suppressed because one or more lines are too long

View file

@ -118,11 +118,11 @@ const VotePositionReadout = ({
/**
* E10 classe. In the table only the pill is visible — spelling the factors out
* under it made this the widest cell in a grid that cannot scroll
* horizontally, so the "por quê" moves to the column's `cellTooltip` (hover,
* focus and tap) and stays as `sr-only` text; the card has the width to show
* it outright. Either way the class never reaches anyone as a bare verdict,
* which is the one thing it must never be.
* under it still bloats the sticky/dense row even with B41's horizontal scroll,
* so the "por quê" moves to the column's `cellTooltip` (hover, focus and tap)
* and stays as `sr-only` text; the card has the width to show it outright.
* Either way the class never reaches anyone as a bare verdict, which is the
* one thing it must never be.
*/
const TerritorialClassReadout = ({
municipality,
@ -262,11 +262,12 @@ const municipalityListColumns = ({
filterOptions={columnFilterOptions.name}
showPriorityFilter={isStaffView}
description={municipalityColumnDescriptions.name}
className="sticky left-0 z-20 min-w-56 bg-background"
>
Município
</MunicipalitySortableHead>
),
cellClassName: 'max-w-52 whitespace-normal',
cellClassName: 'sticky left-0 z-[5] min-w-56 whitespace-normal bg-background',
cell: (municipality) => (
<div className="flex flex-wrap items-center gap-2">
<Link
@ -611,12 +612,11 @@ export const MunicipalityList = (props: MunicipalityListProps) => {
})}
</div>
{/* No inner scroller: the app shell's <main> scrolls, so the sticky header
resolves against it. A sticky <th> can't paint the row border, hence the
inset shadow standing in for it. */}
{/* overflow-visible overrides CampaignTable's overflow-hidden so sticky can
paint. A sticky <th> can't paint the row border, hence the inset shadow. */}
<CampaignTable
className="hidden overflow-visible md:block"
containerClassName="overflow-x-visible"
containerClassName="overflow-x-auto"
headerClassName="[&_th]:sticky [&_th]:top-0 [&_th]:z-10 [&_th]:bg-background [&_th]:shadow-[inset_0_-1px_0_var(--border)] [&_th:first-child]:rounded-tl-xl [&_th:last-child]:rounded-tr-xl [&_tr]:border-b-0"
caption={
<>

View file

@ -109,7 +109,7 @@ type CampaignTableProps<Row> = {
caption?: ReactNode
/** Outer wrapper (border/rounding live here). */
className?: string
/** `ui/Table` scroll container override (e.g. municipality's `overflow-x-visible`). */
/** `ui/Table` scroll container override (e.g. `overflow-x-auto` for sticky-left columns). */
containerClassName?: string
/** `<thead>` override (e.g. sticky headers). */
headerClassName?: string

View file

@ -7,8 +7,9 @@ const Table = ({
containerClassName,
...props
}: React.ComponentProps<'table'> & {
/** Escape hatch for the scroll container — e.g. `overflow-x-visible` so a
* sticky header can resolve against the page scroller instead of this box. */
/** 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. */
containerClassName?: string
}) => (
<div

View file

@ -249,8 +249,13 @@ describe('campaign visual foundation', () => {
// B22: every one of the 10 staff columns carries a header explanation —
// pinned in the `<thead>` only, since `cellTooltip` (e.g. "Classe") adds
// its own triggers in the body.
const [theadHtml] = html.split('<tbody')
const [theadHtml, tbodyHtml = ''] = html.split('<tbody')
expect(theadHtml.match(/data-slot="tooltip-trigger"/g)).toHaveLength(10)
// B41: horizontal scroll + sticky Município (Territory pattern).
expect(html).toMatch(/data-slot="table-container"[^>]*class="[^"]*overflow-x-auto/)
expect(theadHtml).toMatch(/sticky left-0 z-20[^"]*min-w-56[^"]*bg-background/)
expect(tbodyHtml).toMatch(/sticky left-0 z-\[5\][^"]*min-w-56[^"]*bg-background/)
})
/**

View file

@ -37,8 +37,8 @@ describe('CampaignTable cellTooltip', () => {
const html = renderTable()
expect(html).toContain('Seabra')
// No wrapper at all: the municipality list runs with `overflow-x-visible`
// and a sticky header, so an unrequested inline-flex span is a layout risk.
// No wrapper at all: the municipality list uses overflow-x-auto + sticky
// header/first column, so an unrequested inline-flex span is a layout risk.
expect(html).not.toContain('inline-flex')
})