B42: Drawer quick-edit and a tappable card on the mobile municipalities list.

Tendência, Assessores and Votos estimados now share one container
(CampaignCellEditOverlay: Popover on md+, bottom Drawer below), the card drops
to the table's density, and the whole card navigates through a stretched link
instead of a second "Abrir município" button.
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-27 20:49:41 -03:00
parent 1eb8590e99
commit 59d89ce287
17 changed files with 942 additions and 396 deletions

File diff suppressed because one or more lines are too long

View file

@ -31,6 +31,8 @@ A comparação linha a linha dos três (feita nesta triage, `capture-review-debt
- `handleOpenChange`: ao abrir, limpa erro/dirty; ao fechar com um save pendente, faz _flush_ (chama o save direto em vez de deixar o cleanup do unmount descartá-lo) — o **B32** só ganhou esse flush no `/simplify` da própria entrega, espelhando o `flushDraft` que o B24 já tinha.
- `statusMessage` calculado igual nos três (`errorMessage` → "Salvando…" → "Alterações serão salvas automaticamente." → vazio), num `<p className="sr-only" aria-live="polite">`.
**Atualização (2026-07-27, pós-B42 ✓):** o **container** dos três já saiu — `shared/CampaignCellEditOverlay.tsx` (Popover `md+` / Drawer mobile) absorveu Popover/Drawer/trigger/tooltip e `LeadershipListSupportStatusControl` foi migrado como 4º call site. Isso **não** consome o F1 (a casca não toca estado, refs, debounce nem `fetch` — foi o corte deliberado do B42, "só o container"), mas muda duas coisas para a auditoria: os três controles ficaram ~25–40 linhas menores, então a máquina de auto-save é agora a **maior** coisa duplicada neles; e o `/simplify` do B42 acrescentou à lista de peças idênticas um **13º item**, `reportFailure` — `setErrorMessage` + `toast.error` **só quando o overlay já fechou**, porque fechar desmonta o `Alert`/`aria-live` que carregariam o erro. Ele nasceu copiado nos quatro controles (os três do debounce + assessores) no mesmo passe, o que o torna a peça mais recente e mais óbvia do hook, se F1 concluir "extrair".
As diferenças reais, que é o que torna a extração não trivial (achado do `/simplify` reuse review do B32, 2026-07-26): o **shape do valor** diverge (`VoteEstimateScenarioViewModel` objeto com 3 cenários × `MunicipalityListSavedPoliticalTrend` `{status, note}` × `SupportStatus` escalar), **display vs. draft** existe em dois dos três (o B32 colapsou os dois porque só tem um campo — ver "Decisões travadas" do próprio plano B32) e o **B24** tem um guard extra (`inFlightTrendRef`) para não abortar+reenviar o mesmo payload quando blur e fechamento do popover disparam o mesmo flush.
Um quarto controle, `MunicipalityListAdvisorsControl.tsx` (B27, chips + `Command`), **não** compartilha essa máquina — é delta multi-seleção com `requestSeqRef`/`latestConfirmedRef` (fora de escopo, mecânica genuinamente diferente) — mas compartilha com os quatro (incluindo ele) o **preâmbulo do `fetch`** (`credentials: 'same-origin'`, `Content-Type`/`Accept` JSON, `cast` da resposta) e, do lado do servidor, os quatro `route.ts` (`liderancas/support-status`, `municipios/political-trend`, `municipios/expected-votes`, `municipios/advisors`) repetem o mesmo preâmbulo de rota: `isSameOriginRequest(request)` → 403 → `parseCampaignJsonRequestBody` → `bodySchema.parse` → `try/catch` com `campaignJsonMutationErrorResponse`.
@ -97,6 +99,7 @@ Componentes (condicionais à decisão de cada fase):
- `docs/roadmap.md` (Fill-ins abertos — **B32+**)
- [autosave-status-lista-liderancas.md](autosave-status-lista-liderancas.md) — o pai do lote (B32 ✓), onde o gatilho "3º call site" foi fechado
- [autosave-tendencia-lista-municipios.md](autosave-tendencia-lista-municipios.md) (B24) e [combobox-assessores-lista-municipios.md](combobox-assessores-lista-municipios.md) (B27) — os outros dois planos que registraram o mesmo "Adiado com gatilho"
- [polimento-mobile-lista-municipios.md](polimento-mobile-lista-municipios.md) (B42 ✓) — extraiu o **container** dos mesmos três controles (`CampaignCellEditOverlay`) e introduziu `reportFailure` nos quatro; leia o "As-built" antes de auditar o F1
- [escala-dry-pos-b33.md](escala-dry-pos-b33.md) — precedente direto de forma: hook via callbacks (não módulos de domínio), auditoria derrubando a premissa do plano antes de escrever código
- `src/components/campaign/municipality/MunicipalityListExpectedVotesControl.tsx`, `MunicipalityListTrendControl.tsx`, `src/components/campaign/leadership/LeadershipListSupportStatusControl.tsx` — os três call sites de F1
- `src/app/(campaign)/campanha/(app)/liderancas/support-status/route.ts`, `municipios/{political-trend,expected-votes,advisors}/route.ts` — os quatro call sites de F2

View file

@ -1,7 +1,7 @@
# Polimento mobile da lista de municípios (drawers + card compacto)
Status: rascunho
Atualizado em: 2026-07-26
Status: **entregue em código (2026-07-27)** — ver "As-built" no fim
Atualizado em: 2026-07-27
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B, item B42)
Impeccable: B — encaixe nos cards `md:hidden` + controles Tendência/Assessores; sem rota nova
Appetite: ~1 dia eng; Drawer nos dois controles (padrão B26) + densificar card + tap-to-open
@ -110,15 +110,54 @@ Depth check: reusa `Drawer` shadcn + padrão B26; não cria design system de ove
## Adiado com gatilho
- **Tooltip/tap da Classe no card mobile** (mostrar o why sob demanda). Revisitar se a mesa sentir falta após a paridade com desktop.
- **Drawer para votos estimados** no card. Revisitar se o Popover de votos reclamar do teclado como tendência.
- ~~**Tooltip/tap da Classe no card mobile**~~ — **fechado no critique da própria sessão** (ver As-built): o `sr-only` sozinho deixava o veredito inalcançável no toque, o que contraria `DESIGN.md`.
- ~~**Drawer para votos estimados** no card~~ — **entrou nesta entrega** por decisão do usuário; foi o 3º call site que justificou extrair a casca.
- **Slot `footer` + `formId` na casca, para o Sinal (B26) migrar.** Gatilho: um 2º controle precisar de submit explícito num Drawer, ou o Sinal divergir visualmente dos outros três.
- **Montar o Drawer só no primeiro `open`** (`everOpened`, não `open`). Gatilho: a lista mobile aparecer num profile de render, ou o caminho `pagination: false` (435 linhas × 4 overlays) passar a ser rota quente. Números medidos no As-built.
- **`prefers-reduced-motion` no `Drawer`.** O `Dialog`/`Sheet` do kit também não respeita; a animação de entrada é do base-ui e vem do kit, então a correção é do componente `ui/`, não deste item. Gatilho: qualquer trabalho de acessibilidade de movimento no kit.
- **`DrawerVirtualKeyboardProvider`.** Sem ele, o teclado do Android pode cobrir os inputs de votos dentro da folha; hoje mitigado por não focar campo na abertura. Gatilho: relato de campo em Android.
## As-built (2026-07-27)
**Escopo ampliado por decisão do usuário na sessão:** **Votos estimados entrou** no mesmo tratamento (estava em "Adiado com gatilho" acima). Isso levou o item de 2 para **3 controles com a mesma casca** — que é o critério de extração do repo —, então a **questão em aberto foi resolvida ao contrário da recomendação**: `src/components/campaign/shared/CampaignCellEditOverlay.tsx` nasceu já nesta entrega, não no `/simplify`. Appetite ~1d → ~1,25d.
- **`CampaignCellEditOverlay`** carrega só o container: `variant` (`popover` | `sheet`), `open`/`onOpenChange`, `title`/`description`, `trigger`/`triggerLabel`, `tooltipContent` (B23, só no popover — não há hover no toque) e `preventPopoverAutoFocus`. **Cada controle continua dono do seu auto-save** (debounce, abort, flush no fechamento): fechar o Drawer grava exatamente como dispensar o Popover, porque o `handleOpenChange` de cada um passa direto para `onOpenChange`.
- **O Sinal (B26) não migrou — mas o motivo registrado antes estava errado.** O `/simplify` derrubou a alegação de que "a casca não expressa isso sem virar genérica": o `<form>` **não** precisa envolver o `DrawerHeader` (um título não é campo) e um submit fora do form é HTML padrão via `form="<id>"`. O que falta é um **slot `footer`** (mais um `formId` do chamador) — adiado com gatilho abaixo, não impossível. O Sinal segue sendo o precedente visual e agora **consome** a classe de trigger da casca (`campaignCellEditTriggerClassName`) e os três atributos ARIA que lhe faltavam (`aria-expanded`/`aria-haspopup`/`aria-busy`).
- **`useIsMobile` descartado:** os cards (`md:hidden`) e a tabela (`hidden md:block`) são árvores irmãs, então o call site já sabe em que viewport está. Uma media query aqui trocaria o container depois da hidratação.
- **Navegação do card = link esticado, zero JS:** `<Link>` dentro do `<h3>` com `after:absolute after:inset-0` sobre o `<article className="relative">`. **Quem é `relative` é o controle, não a célula** — a primeira versão posicionou os seis `<div>` do `<dl>`, o que levantou também os rótulos e o padding de cada célula acima do overlay e transformou metade do card em toque que não editava nem navegava (P0 do critique). Hoje o `relative` mora na classe compartilhada do trigger; nada mais dentro do card pode ser posicionado. `MunicipalityList` continua Server Component e o foco/teclado seguem nativos. O botão "Abrir município" saiu.
- **Dois props `layout` morreram** com a paridade pedida: `TerritorialClassReadout` (badge + `sr-only` do porquê, como na tabela — E10 nunca entrega veredito nu) e `MunicipalityListGoalCoverageCell` (sempre `compact`). Com um único comportamento cada, o prop e o branch órfão foram deletados.
- **`autoFocusScenario` suprimido no `sheet`:** no desktop o Popover é transitório e digitar é o ponto; no toque, focar um `input` na abertura subiria o teclado virtual antes de o número ser lido.
- **`municipalityName` é obrigatório exatamente onde há Drawer:** os props de Votos estimados são uma união discriminada — `sheet` **exige** o nome (uma folha sempre nomeia seu assunto, e isso é erro de compilação, não nota de revisão), `popover` o deixa **opcional**, porque o 3º call site (`MunicipalityGoalAccountCard`, na ficha do município) já está sob o título do próprio município. A primeira versão **proibia** o nome no `popover` (`?: never`), o que criou uma assimetria na mesma linha da tabela: Tendência e Assessores anunciavam "… em Feira de Santana — …" e Votos estimados não. Resolvido para o lado de incluir depois de verificar que a célula do nome é um `<td>` comum, **não** um `<th scope="row">` — não existe associação de cabeçalho de linha para o leitor de tela suprir o contexto quando o foco entra num trigger. Custo medido do nome nos dois variants: **0,22 KiB gzip** na página de 25 linhas (2,9 KiB no caminho `pagination: false` de 435).
**Testes:** `tests/unit/campaignCellEditOverlay.unit.spec.ts` (jsdom, 6 casos = 3 controles × 2 variants: `sheet` abre `role="dialog"` com título + nome do município e botão "Fechar"; `popover` mantém `popover-content` e não repete o nome) — precisou dos stubs de `ResizeObserver` e `Element.prototype.scrollIntoView` que o `cmdk` do combobox de assessores exige. E2e novo `test.describe` com `viewport: { width: 390, height: 844 }` em `campaignMunicipalities.e2e.spec.ts`: abrir Tendência no card **não** navega, e o toque fora dos controles abre `/campanha/municipios/<slug>`.
**Gate:** tsc, `lint --max-warnings=0`, `format:check`, knip (só o erro pré-existente de `payload.config.ts`, P3), `check:cycles`, **592 unit / 420 int**, build (`/campanha/municipios` 13,4 kB / 320 kB First Load JS) e Aikido sem achados. O e2e mobile passou; as falhas rotativas da suíte cheia foram reproduzidas **piores na árvore limpa** (4 falhas contra 3, o mesmo teste de demanda falhando na mesma linha) sob contenção de outra sessão Playwright no mesmo host — mesmo padrão de ambiente já registrado no B27 ✓/B32 ✓, não regressão.
### Critique + `/simplify` (mesma sessão, depois do gate)
O critique rodou **sobre o código**, não sobre a tela: a coleta de evidência visual em viewport 390×844 falhou por falta de automação de browser nesta sessão, então nada aqui foi confirmado em device real — os alvos de toque são checados por classe (`min-h-11`), não medidos. Mesmo assim achou um **P0 de correção no gesto central**, e os revisores paralelos (qualidade, performance, reuso) convergiram no resto. O que entrou:
- **P0 — zonas mortas do link esticado.** Descrito no bullet de navegação acima. O e2e passou a tocar num **rótulo `dt` dentro da grade de métricas** (não mais no canto do card), que é exatamente a área que o bug engolia.
- **O Drawer abre com foco no título, nunca num campo.** `initialFocus={titleRef}` num `DrawerTitle` com `tabIndex={-1}`: o default do base-ui (primeiro tabbable) subia o teclado virtual sobre a folha antes de ela ser lida — e o `preventContentAutoFocus` que devia mitigar isso era **inerte no branch `sheet`**, um prop que mentia. Renomeado para `preventPopoverAutoFocus` (é só do Popover) e o comportamento do Drawer virou incondicional. Pinado no unit spec, porque é o ponto mais frágil da entrega.
- **Rótulo acessível carrega o valor.** Um `aria-label` **substitui** o conteúdo do trigger: as quatro células de edição anunciavam o verbo e engoliam justamente o valor que todo mundo lê no pill/no avatar/no número. Agora seguem a forma que o Sinal (B26) já usava — "Editar tendência política em Feira de Santana — Favorável". Isso resolveu o achado de payload do revisor de performance **ao contrário** do que ele propôs: ele mediu `municipalityName` como payload RSC **morto** no desktop (27,9 KiB crus / 2,9 KiB gzip em 435 linhas) e sugeriu proibi-lo no `popover` nos três controles; com o nome dentro do rótulo ele deixou de ser morto, e a célula do nome ser um `<td>` sem `scope="row"` é o que diz que ninguém mais fornece esse contexto (ver o bullet do `municipalityName` acima).
- **Falha silenciosa depois do fechamento.** Fechar é o que **commita** o rascunho e também o que desmonta o `Alert` e a região `aria-live` que carregariam o erro: uma gravação que falhava na saída revertia o badge sem avisar ninguém. Os três controles de auto-save por debounce (+ o de lideranças) ganharam `toast.error` **apenas quando já estão fechados**; abertos, o `Alert` inline continua sendo o canal mais próximo.
- **Alvos de toque dentro do Drawer.** Os três inputs compactos de votos passam a 44px abaixo de `md` e continuam 36px no `md+` (única call site do variant `compact`); o chip de remover assessor e cada linha do `Command` ganham `min-h-11` só no `sheet`.
- **A explicação voltou a ser alcançável no toque.** A Classe no card ganhou `CampaignHoverTooltip` (o canal tap-to-open que a tabela já tem via `cellTooltip`) — `DESIGN.md` exige que a classe nunca chegue como veredito nu, e no card ela só tinha `sr-only`; a Cobertura ganhou a frase completa em `sr-only`, porque `title` não é anunciado nem alcançado por toque. Isso **fecha** o "Adiado com gatilho: tooltip/tap da Classe no card mobile".
- **Reuso — o 4º call site migrou.** `LeadershipListSupportStatusControl` (B32) era o branch Popover da casca redigitado à mão, classe de trigger byte-a-byte inclusive: migrado com `variant="popover"`, ~25 linhas a menos e nenhum prop novo. `MunicipalityListSignalControl` passou a importar `CampaignCellEditOverlayVariant` em vez de re-escrever a união. A string de trigger, que estava em três arquivos, virou `campaignCellEditTriggerClassName` (precedente: `campaignHoverExplanationClassName`).
- **API da casca:** o `<button>` do trigger era escrito duas vezes (só o `onClick` diferia) → uma vez; `sheetBodyClassName` era o único prop de classe que **substituía** em vez de compor, o que obrigava o chamador de assessores a re-espalhar `px-4` por seção → agora compõe (`px-0 pt-2` no chamador) e o `sectionPaddingClassName` condicional morreu; `title` era obrigatório e morto no Popover → virou o `aria-label` do `role="dialog"`, que o Radix não nomeia; o docblock estava colado no `type` do variant (hover do IDE mostrava a doc errada) e os props agora estão agrupados em blocos `// Popover only` / `// Sheet only`.
**Medido e não feito** (o revisor de performance mediu; a decisão é não pagar agora): cada Drawer fechado ainda executa o corpo de `DrawerContent`, e o `cn()` do popup roda `tailwind-merge` sobre **4.068 caracteres** — 6,2 µs contra 0,8 µs do Popover que ele substituiu, ou **+0,40 ms** em 25 linhas e **+7 ms** no caminho `pagination: false` de 435. Não é adição, é troca (o número de overlays por página não mudou), e a correção é montar o Drawer no primeiro `open` (`{everOpened && …}`, não `{open && …}`, senão o `data-starting-style` perde a animação de entrada). Registrado como débito com gatilho.
**Cuidado operacional aprendido aqui:** `git stash` é **compartilhado entre worktrees** do mesmo repositório. Fazer stash para medir uma baseline enquanto outra sessão trabalha em paralelo faz o `pop` trazer o WIP alheio (e o índice do `drop` deslizar) — aconteceu nesta sessão e foi revertido com `git stash store <hash>`. Para baseline com sessões concorrentes, prefira patch local (`git diff > /tmp/…` + `git checkout --`) a `git stash`.
## Referências
- `docs/roadmap.md` (Trilha B · B42)
- `src/components/campaign/municipality/MunicipalityList.tsx`
- `MunicipalityListTrendControl.tsx`, `MunicipalityListAdvisorsControl.tsx`, `MunicipalityListSignalControl.tsx`
- `MunicipalityListGoalCoverageCell.tsx`
- `src/components/campaign/shared/CampaignCellEditOverlay.tsx` (casca `popover` | `sheet` + `campaignCellEditTriggerClassName`)
- `MunicipalityListTrendControl.tsx`, `MunicipalityListAdvisorsControl.tsx`, `MunicipalityListExpectedVotesControl.tsx`, `MunicipalityListSignalControl.tsx`
- `MunicipalityListGoalCoverageCell.tsx`, `TerritorialClassReadout.tsx`
- `src/components/campaign/leadership/LeadershipListSupportStatusControl.tsx` (4º call site da casca)
- `docs/plans/registrar-sinal-lista-municipios.md` (B26)
- `docs/plans/autosave-tendencia-lista-municipios.md` (B24)
- `docs/plans/combobox-assessores-lista-municipios.md` (B27)

File diff suppressed because one or more lines are too long

View file

@ -1,20 +1,20 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { toast } from 'sonner'
import type { LeadershipListSupportStatusResponse } from '@/app/(campaign)/campanha/(app)/liderancas/support-status/types'
import { SupportStatusBadge } from '@/components/campaign/leadership/SupportStatusBadge'
import { CampaignCellEditOverlay } from '@/components/campaign/shared/CampaignCellEditOverlay'
import { Alert, AlertDescription } from '@/components/ui/Alert'
import { Field, FieldLabel } from '@/components/ui/field'
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover'
import { Spinner } from '@/components/ui/Spinner'
import {
isSupportStatus,
leadershipSupportStatuses,
type SupportStatus,
} from '@/lib/schemas/leadership'
import { cn } from '@/lib/utils'
import { supportStatusLabels } from '@/utilities/leadershipLabels'
const STATUS_AUTOSAVE_MS = 150
@ -46,6 +46,18 @@ export const LeadershipListSupportStatusControl = ({
const committedStatusRef = useRef(initialStatus)
const lastPropsStatusRef = useRef(initialStatus)
const saveGenerationRef = useRef(0)
const openRef = useRef(false)
/**
* Closing flushes the pending save and unmounts the Alert that would carry a
* failure, so a save that fails on the way out would revert the badge with
* nobody told. Same channel as the município controls: toast once closed,
* inline Alert while open.
*/
const reportFailure = (message: string) => {
setErrorMessage(message)
if (!openRef.current) toast.error(message)
}
// Adopt server props only when they change from outside (navigation / RSC refresh).
useEffect(() => {
@ -92,7 +104,7 @@ export const LeadershipListSupportStatusControl = ({
if (!response.ok || payload.status !== 'success') {
setValue(committedStatusRef.current)
setIsDirty(false)
setErrorMessage(payload.status === 'error' ? payload.message : SAVE_ERROR_MESSAGE)
reportFailure(payload.status === 'error' ? payload.message : SAVE_ERROR_MESSAGE)
return
}
@ -103,7 +115,7 @@ export const LeadershipListSupportStatusControl = ({
if (controller.signal.aborted || generation !== saveGenerationRef.current) return
setValue(committedStatusRef.current)
setIsDirty(false)
setErrorMessage(SAVE_ERROR_MESSAGE)
reportFailure(SAVE_ERROR_MESSAGE)
} finally {
if (generation === saveGenerationRef.current) {
setIsPending(false)
@ -123,6 +135,7 @@ export const LeadershipListSupportStatusControl = ({
}
const handleOpenChange = (nextOpen: boolean) => {
openRef.current = nextOpen
if (nextOpen) {
setIsDirty(false)
setErrorMessage(null)
@ -147,62 +160,53 @@ export const LeadershipListSupportStatusControl = ({
: ''
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<button
type="button"
aria-expanded={open}
aria-haspopup="dialog"
aria-busy={isPending || undefined}
className={cn(
'min-h-11 rounded-md px-1 hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
open ? 'bg-muted/60' : undefined,
)}
aria-label="Editar status de apoio"
>
<SupportStatusBadge status={value} />
</button>
</PopoverTrigger>
<PopoverContent
align="start"
sideOffset={8}
className="w-64 p-3"
onOpenAutoFocus={(event) => event.preventDefault()}
>
<div className="relative flex flex-col gap-3">
{isPending ? (
<Spinner
className="absolute top-0 right-0 size-3.5 text-muted-foreground"
aria-label="Salvando status de apoio"
/>
) : null}
<Field>
<FieldLabel htmlFor={`leadership-list-support-status-${leadershipID}`}>
Status de apoio
</FieldLabel>
<NativeSelect
id={`leadership-list-support-status-${leadershipID}`}
value={value}
onChange={(event) => handleStatusChange(event.target.value)}
className="min-h-11 w-full"
>
{leadershipSupportStatuses.map((option) => (
<NativeSelectOption key={option} value={option}>
{supportStatusLabels[option]}
</NativeSelectOption>
))}
</NativeSelect>
</Field>
{errorMessage ? (
<Alert variant="destructive" className="py-2">
<AlertDescription className="text-xs">{errorMessage}</AlertDescription>
</Alert>
) : null}
<p className="sr-only" aria-live="polite">
{statusMessage}
</p>
</div>
</PopoverContent>
</Popover>
// The leaderships list is table-only, so this stays a Popover at every
// viewport — the shell is here for the trigger and content it already owns,
// not for a Drawer this surface has nowhere to put (B42).
<CampaignCellEditOverlay
variant="popover"
open={open}
onOpenChange={handleOpenChange}
title="Editar status de apoio"
triggerLabel={`Editar status de apoio — ${supportStatusLabels[value]}`}
triggerBusy={isPending}
contentClassName="w-64 p-3"
preventPopoverAutoFocus
trigger={<SupportStatusBadge status={value} />}
>
<div className="relative flex flex-col gap-3">
{isPending ? (
<Spinner
className="absolute top-0 right-0 size-3.5 text-muted-foreground"
aria-label="Salvando status de apoio"
/>
) : null}
<Field>
<FieldLabel htmlFor={`leadership-list-support-status-${leadershipID}`}>
Status de apoio
</FieldLabel>
<NativeSelect
id={`leadership-list-support-status-${leadershipID}`}
value={value}
onChange={(event) => handleStatusChange(event.target.value)}
className="min-h-11 w-full"
>
{leadershipSupportStatuses.map((option) => (
<NativeSelectOption key={option} value={option}>
{supportStatusLabels[option]}
</NativeSelectOption>
))}
</NativeSelect>
</Field>
{errorMessage ? (
<Alert variant="destructive" className="py-2">
<AlertDescription className="text-xs">{errorMessage}</AlertDescription>
</Alert>
) : null}
<p className="sr-only" aria-live="polite">
{statusMessage}
</p>
</div>
</CampaignCellEditOverlay>
)
}

View file

@ -262,6 +262,7 @@ export const MunicipalityGoalAccountCard = ({
municipalityID={municipalityID}
expectedVotes={expectedVotes}
pledgeCoverage={pledgeCoverage}
variant="popover"
/>
<p className="mt-1 text-xs text-muted-foreground">
Meta usada na conta: {formatElectionNumber(goalCoverage.goal)} (

View file

@ -14,10 +14,11 @@ import { MunicipalityListSignalControl } from '@/components/campaign/municipalit
import { MunicipalityListTrendControl } from '@/components/campaign/municipality/MunicipalityListTrendControl'
import { MunicipalitySortableHead } from '@/components/campaign/municipality/MunicipalitySortableHead'
import { TerritoryLink } from '@/components/campaign/municipality/TerritoryLink'
import { CampaignHoverTooltip } from '@/components/campaign/shared/CampaignHoverTooltip'
import { CampaignTransitionAnchor } from '@/components/campaign/shared/CampaignListPending'
import { CampaignTable, type CampaignTableColumn } from '@/components/campaign/shared/CampaignTable'
import { Badge } from '@/components/ui/Badge'
import { Button, buttonVariants } from '@/components/ui/button'
import { buttonVariants } from '@/components/ui/button'
import {
Empty,
EmptyContent,
@ -117,32 +118,24 @@ const VotePositionReadout = ({
}
/**
* E10 classe. In the table only the pill is visible — spelling the factors out
* under it still bloats the sticky/dense row even with 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.
* E10 classe. Only the pill is visible — spelling the factors out under it
* bloats the dense row on desktop and, on the mobile card, was the tallest
* block in a card the field asked us to shorten (B42). So the "por quê" is
* `sr-only` text here, and both surfaces add a tap/hover/focus channel around
* it: the column's `cellTooltip` in the table, an explicit `CampaignHoverTooltip`
* on the card. Neither may be dropped — the class must never reach anyone,
* sighted or not, as a bare verdict.
*/
const TerritorialClassReadout = ({
municipality,
layout,
}: {
municipality: MunicipalityListViewModel
layout: 'table' | 'card'
}) => {
const isTable = layout === 'table'
const TerritorialClassReadout = ({ municipality }: { municipality: MunicipalityListViewModel }) => {
const why = formatTerritorialClassWhy(municipality.territorialClassFactors)
if (municipality.territorialClass === 'sem_base') {
// Same idiom as the "2022" column: absent data is a dash, not a pill.
return isTable ? (
return (
<span className="text-muted-foreground">
<span aria-hidden="true">—</span>
<span className="sr-only">{why}</span>
</span>
) : (
<span className="text-xs text-muted-foreground">{why}</span>
)
}
@ -151,7 +144,7 @@ const TerritorialClassReadout = ({
<Badge variant={territorialClassBadgeVariant[municipality.territorialClass]}>
{territorialClassLabels[municipality.territorialClass]}
</Badge>
<span className={isTable ? 'sr-only' : 'text-xs text-muted-foreground'}>{why}</span>
<span className="sr-only">{why}</span>
</>
)
}
@ -346,9 +339,7 @@ const municipalityListColumns = ({
Classe
</MunicipalitySortableHead>
),
cell: (municipality) => (
<TerritorialClassReadout municipality={municipality} layout="table" />
),
cell: (municipality) => <TerritorialClassReadout municipality={municipality} />,
// The factors behind the label — the cell keeps them as `sr-only`
// text, so this stays a redundant affordance (see `cellTooltip`).
cellTooltip: (municipality) =>
@ -373,10 +364,12 @@ const municipalityListColumns = ({
isCoordinator ? (
<MunicipalityListAdvisorsControl
municipalityID={municipality.id}
municipalityName={municipality.name}
currentAdvisorIDs={municipality.advisorIDs}
isPriority={municipality.priority === 'alta'}
advisorNamesById={advisorNamesById}
options={advisorOptions}
variant="popover"
/>
) : (
<MunicipalityAdvisorAvatarStack
@ -407,8 +400,10 @@ const municipalityListColumns = ({
cell: (municipality) => (
<MunicipalityListTrendControl
municipalityID={municipality.id}
municipalityName={municipality.name}
status={municipality.politicalTrendStatus}
trendNote={municipality.politicalTrendNote}
variant="popover"
/>
),
},
@ -429,8 +424,10 @@ const municipalityListColumns = ({
<div className="flex min-h-11 items-center justify-center">
<MunicipalityListExpectedVotesControl
municipalityID={municipality.id}
municipalityName={municipality.name}
expectedVotes={municipality.expectedVotes}
pledgeCoverage={toMunicipalityPledgeCoverageView(municipality.pledges)}
variant="popover"
/>
</div>
),
@ -473,7 +470,6 @@ const municipalityListColumns = ({
cell: (municipality) => (
<MunicipalityListGoalCoverageCell
coverageByScenario={municipality.goalCoverageByScenario}
layout="compact"
/>
),
},
@ -519,12 +515,33 @@ export const MunicipalityList = (props: MunicipalityListProps) => {
const isPriority = municipality.priority === 'alta'
const { region, zoneSuffix } = municipalityGeographyParts(municipality)
return (
<article key={municipality.id} className="flex flex-col gap-3 rounded-xl border p-4">
<article
key={municipality.id}
className="relative flex flex-col gap-3 rounded-xl border p-4"
>
<div className="flex items-start justify-between gap-2">
<div className="flex flex-col gap-1">
<h3 className="font-medium">{municipality.name}</h3>
{/*
* The name's link stretches over the whole card, so tapping
* anywhere that isn't a control opens the município — B42
* removed the "Abrir município" button that used to be the
* only way in. Each control carries its own `relative`,
* which paints it above this overlay and keeps its own tap;
* nothing else in the card may be positioned, or it would
* become a hole in the card's tap target.
*/}
<h3 className="font-medium">
<Link
href={`/campanha/municipios/${municipality.slug}`}
className="rounded-md after:absolute after:inset-0 after:rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{municipality.name}
</Link>
</h3>
<p className="text-sm text-muted-foreground">
<TerritoryLink region={region} />
<span className="relative">
<TerritoryLink region={region} />
</span>
{zoneSuffix ? ` ${zoneSuffix}` : null}
</p>
{position ? <VotePositionReadout position={position} layout="card" /> : null}
@ -535,19 +552,46 @@ export const MunicipalityList = (props: MunicipalityListProps) => {
</div>
{isStaffView ? (
<dl className="grid grid-cols-2 gap-2 text-sm">
<div className="col-span-2">
<div>
<dt className="text-muted-foreground">Classe</dt>
<dd className="flex flex-wrap items-center gap-2">
<TerritorialClassReadout municipality={municipality} layout="card" />
{/*
* The card has no hover, so the "por quê" the class must
* never travel without gets the same tap-to-open channel
* the desktop column gets through `cellTooltip` — the
* `sr-only` copy inside the readout covers AT either way.
*/}
<CampaignHoverTooltip
content={
municipality.territorialClass === 'sem_base'
? null
: formatTerritorialClassWhy(municipality.territorialClassFactors)
}
align="start"
>
<span className="relative inline-flex items-center">
<TerritorialClassReadout municipality={municipality} />
</span>
</CampaignHoverTooltip>
</dd>
</div>
<div>
<dt className="text-muted-foreground">Cobertura da meta</dt>
<dd>
<MunicipalityListGoalCoverageCell
coverageByScenario={municipality.goalCoverageByScenario}
/>
</dd>
</div>
<div>
<dt className="text-muted-foreground">Votos estimados</dt>
<dd className="flex justify-center">
<dd>
<MunicipalityListExpectedVotesControl
municipalityID={municipality.id}
municipalityName={municipality.name}
expectedVotes={municipality.expectedVotes}
pledgeCoverage={toMunicipalityPledgeCoverageView(municipality.pledges)}
variant="sheet"
/>
</dd>
</div>
@ -556,16 +600,10 @@ export const MunicipalityList = (props: MunicipalityListProps) => {
<dd>
<MunicipalityListTrendControl
municipalityID={municipality.id}
municipalityName={municipality.name}
status={municipality.politicalTrendStatus}
trendNote={municipality.politicalTrendNote}
/>
</dd>
</div>
<div className="col-span-2">
<dt className="text-muted-foreground">Cobertura da meta</dt>
<dd>
<MunicipalityListGoalCoverageCell
coverageByScenario={municipality.goalCoverageByScenario}
variant="sheet"
/>
</dd>
</div>
@ -590,10 +628,12 @@ export const MunicipalityList = (props: MunicipalityListProps) => {
{isCoordinator ? (
<MunicipalityListAdvisorsControl
municipalityID={municipality.id}
municipalityName={municipality.name}
currentAdvisorIDs={municipality.advisorIDs}
isPriority={isPriority}
advisorNamesById={advisorNamesById}
options={advisorOptions}
variant="sheet"
/>
) : names.length ? (
names.join(', ')
@ -604,9 +644,6 @@ export const MunicipalityList = (props: MunicipalityListProps) => {
</div>
</dl>
) : null}
<Button asChild variant="outline" className="min-h-11 w-full">
<Link href={`/campanha/municipios/${municipality.slug}`}>Abrir município</Link>
</Button>
</article>
)
})}

View file

@ -9,11 +9,13 @@ import {
formatAdvisorNamesTooltip,
MunicipalityAdvisorAvatarStack,
} from '@/components/campaign/municipality/MunicipalityAdvisorAvatarStack'
import { CampaignHoverTooltip } from '@/components/campaign/shared/CampaignHoverTooltip'
import {
CampaignCellEditOverlay,
type CampaignCellEditOverlayVariant,
} from '@/components/campaign/shared/CampaignCellEditOverlay'
import { Alert, AlertDescription } from '@/components/ui/Alert'
import { Badge } from '@/components/ui/Badge'
import { Command, CommandInput, CommandItem, CommandList } from '@/components/ui/Command'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover'
import { Spinner } from '@/components/ui/Spinner'
import { sameIdSet } from '@/lib/sameIdSet'
import { cn } from '@/lib/utils'
@ -28,19 +30,23 @@ const SAVE_ERROR_MESSAGE = 'Não foi possível atualizar os assessores. Tente no
type MunicipalityListAdvisorsControlProps = {
municipalityID: number
municipalityName: string
currentAdvisorIDs: number[]
/** Raises the empty state to "Sem responsável" — see `MissingAdvisorBadge`. */
isPriority: boolean
advisorNamesById: ReadonlyMap<number, MunicipalityAdvisorSummary>
options: EligibleAdvisorOption[]
variant: CampaignCellEditOverlayVariant
}
export const MunicipalityListAdvisorsControl = ({
municipalityID,
municipalityName,
currentAdvisorIDs,
isPriority,
advisorNamesById,
options,
variant,
}: MunicipalityListAdvisorsControlProps) => {
const [open, setOpen] = useState(false)
const [selectedIDs, setSelectedIDs] = useState<number[]>(currentAdvisorIDs)
@ -95,6 +101,11 @@ export const MunicipalityListAdvisorsControl = ({
// chips: it's the no-open-popover read. `null` for an empty list — the
// trigger already reads "Sem responsável"/"Sem assessor" by extenso.
const tooltipContent = formatAdvisorNamesTooltip(chips)
const assignedLabel = chips.length
? chips.map((chip) => chip.name).join(', ')
: isPriority
? 'sem responsável'
: 'sem assessor'
const filteredOptions = useMemo(
() => options.filter((option) => matchesAtWordStart(option.name, query)),
@ -167,106 +178,109 @@ export const MunicipalityListAdvisorsControl = ({
}
const statusMessage = errorMessage ? errorMessage : isPending ? 'Salvando assessores.' : ''
// The body pads its own sections (`px-4`, aligned with the Drawer header) so
// the `Command` can stay full-bleed in both containers — hence `px-0` on the
// sheet body and `p-0` on the popover content.
const isSheet = variant === 'sheet'
return (
<Popover open={open} onOpenChange={setOpen}>
<CampaignHoverTooltip
content={tooltipContent}
align="start"
openOnTouch={false}
disabled={open}
>
<PopoverTrigger asChild>
<button
type="button"
aria-expanded={open}
aria-haspopup="dialog"
className={cn(
'min-h-11 rounded-md px-1 hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
open ? 'bg-muted/60' : undefined,
)}
aria-label="Editar assessores"
>
<MunicipalityAdvisorAvatarStack advisors={chips} isPriority={isPriority} />
</button>
</PopoverTrigger>
</CampaignHoverTooltip>
<PopoverContent align="start" className="w-80 p-0">
<div className="relative flex flex-col gap-2 p-3 pb-0">
<p className="text-sm font-medium">Atribuir assessores</p>
<p className="text-xs text-muted-foreground">
O assessor vê e gerencia somente os municípios que administra.
</p>
{isPending ? (
<Spinner
className="absolute top-3 right-3 size-3.5 text-muted-foreground"
aria-label="Salvando assessores"
/>
) : null}
</div>
{chips.length > 0 ? (
<div className="flex flex-wrap gap-1.5 px-3 pt-2">
{chips.map((chip) => (
<button
key={chip.id}
type="button"
aria-label={`Remover ${chip.name}`}
onClick={() => toggle(chip.id, false)}
>
<Badge
variant="secondary"
className="max-w-full cursor-pointer gap-1 pr-1 font-normal hover:bg-destructive/15"
>
<span className="truncate">{chip.name}</span>
<XIcon className="size-3 shrink-0 opacity-70" aria-hidden="true" />
</Badge>
</button>
))}
</div>
) : null}
<Command shouldFilter={false} className="mt-1">
<CommandInput
value={query}
onValueChange={setQuery}
placeholder="Buscar assessor…"
aria-label="Buscar assessor"
/>
<CommandList>
{filteredOptions.length === 0 ? (
<p className="px-3 py-6 text-center text-sm text-muted-foreground">
Nenhum resultado.
</p>
) : (
filteredOptions.map((option) => {
const assigned = selectedSet.has(option.id)
return (
<CommandItem
key={option.id}
value={`advisor-${option.id}`}
data-checked={assigned}
onSelect={() => toggle(option.id, !assigned)}
>
<span className="truncate">
{option.name}
{option.isCurrent ? ' (você)' : ''}
</span>
</CommandItem>
)
})
)}
</CommandList>
</Command>
{errorMessage ? (
<div className="p-3 pt-2">
<Alert variant="destructive">
<AlertDescription>{errorMessage}</AlertDescription>
</Alert>
</div>
) : null}
<p className="sr-only" aria-live="polite">
{statusMessage}
<CampaignCellEditOverlay
variant={variant}
open={open}
onOpenChange={setOpen}
title="Atribuir assessores"
description={municipalityName}
// The avatar stack reads as initials at best, and an `aria-label` replaces
// even those — so who is assigned goes in the label, by extenso.
triggerLabel={`Editar assessores em ${municipalityName} — ${assignedLabel}`}
triggerBusy={isPending}
tooltipContent={tooltipContent}
contentClassName="w-80 p-0"
sheetBodyClassName="px-0 pt-2"
trigger={<MunicipalityAdvisorAvatarStack advisors={chips} isPriority={isPriority} />}
>
<div className={cn('relative flex shrink-0 flex-col gap-2 px-4 pb-0', !isSheet && 'pt-3')}>
{/* The Drawer's own header already titles it. */}
{isSheet ? null : <p className="text-sm font-medium">Atribuir assessores</p>}
{/* `pr-6` keeps this line clear of the sheet's spinner, which sits at
`top-0 right-4` because there is no title row above it to hold it. */}
<p className={cn('text-xs text-muted-foreground', isSheet && 'pr-6')}>
O assessor vê e gerencia somente os municípios que administra.
</p>
</PopoverContent>
</Popover>
{isPending ? (
<Spinner
className={cn(
'absolute size-3.5 text-muted-foreground',
isSheet ? 'top-0 right-4' : 'top-3 right-3',
)}
aria-label="Salvando assessores"
/>
) : null}
</div>
{chips.length > 0 ? (
<div className="flex shrink-0 flex-wrap gap-1.5 px-4 pt-2">
{chips.map((chip) => (
<button
key={chip.id}
type="button"
// A 20px pill is a fine mouse target and a bad thumb one: the
// Drawer pads it to the 44px minimum without growing the pill.
className={cn('inline-flex items-center', isSheet && 'min-h-11')}
aria-label={`Remover ${chip.name}`}
onClick={() => toggle(chip.id, false)}
>
<Badge
variant="secondary"
className="max-w-full cursor-pointer gap-1 pr-1 font-normal hover:bg-destructive/15"
>
<span className="truncate">{chip.name}</span>
<XIcon className="size-3 shrink-0 opacity-70" aria-hidden="true" />
</Badge>
</button>
))}
</div>
) : null}
<Command shouldFilter={false} className="mt-1">
<CommandInput
value={query}
onValueChange={setQuery}
placeholder="Buscar assessor…"
aria-label="Buscar assessor"
/>
<CommandList>
{filteredOptions.length === 0 ? (
<p className="px-3 py-6 text-center text-sm text-muted-foreground">Nenhum resultado.</p>
) : (
filteredOptions.map((option) => {
const assigned = selectedSet.has(option.id)
return (
<CommandItem
key={option.id}
value={`advisor-${option.id}`}
data-checked={assigned}
className={isSheet ? 'min-h-11' : undefined}
onSelect={() => toggle(option.id, !assigned)}
>
<span className="truncate">
{option.name}
{option.isCurrent ? ' (você)' : ''}
</span>
</CommandItem>
)
})
)}
</CommandList>
</Command>
{errorMessage ? (
<div className="shrink-0 px-4 pt-2 pb-3">
<Alert variant="destructive">
<AlertDescription>{errorMessage}</AlertDescription>
</Alert>
</div>
) : null}
<p className="sr-only" aria-live="polite">
{statusMessage}
</p>
</CampaignCellEditOverlay>
)
}

View file

@ -1,17 +1,19 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { toast } from 'sonner'
import type { MunicipalityListExpectedVotesResponse } from '@/app/(campaign)/campanha/(app)/municipios/expected-votes/types'
import { useMunicipalityEstimateScenarioOptional } from '@/components/campaign/municipality/MunicipalityEstimateScenarioContext'
import { CampaignCellEditOverlay } from '@/components/campaign/shared/CampaignCellEditOverlay'
import { StaffMunicipalityVotesDisplay } from '@/components/campaign/votePledge/StaffMunicipalityVotesDisplay'
import { VoteEstimateScenarioInputs } from '@/components/campaign/votePledge/VoteEstimateScenarioInputs'
import { Alert, AlertDescription } from '@/components/ui/Alert'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover'
import { Spinner } from '@/components/ui/Spinner'
import { cn } from '@/lib/utils'
import { formatElectionNumber } from '@/lib/electionFormat'
import {
DEFAULT_VOTE_ESTIMATE_SCENARIO,
voteEstimateScenarioLabels,
voteEstimatesEqual,
type VoteEstimateScenarioViewModel,
} from '@/lib/voteEstimate'
@ -21,16 +23,30 @@ const AUTOSAVE_MS = 600
const EXPECTED_VOTES_ENDPOINT = '/campanha/municipios/expected-votes'
const SAVE_ERROR_MESSAGE = 'Não foi possível salvar os votos estimados. Tente novamente.'
/**
* `municipalityName` is the Drawer's subject line and part of the trigger's
* accessible name, so a Drawer must always name its subject — the union makes
* that a compile error instead of a review note. It stays optional for a Popover
* because the third call site (`MunicipalityGoalAccountCard` on the detail page)
* already sits under the município's own heading; inside the list, the Popover
* does pass it, since the name cell is a plain `<td>` and not a `<th scope="row">`,
* so nothing else announces which row a focused trigger belongs to.
*/
type MunicipalityListExpectedVotesControlProps = {
municipalityID: number
expectedVotes: VoteEstimateScenarioViewModel
pledgeCoverage: MunicipalityPledgeCoverageView | null
}
} & (
| { variant: 'popover'; municipalityName?: string }
| { variant: 'sheet'; municipalityName: string }
)
export const MunicipalityListExpectedVotesControl = ({
municipalityID,
municipalityName,
expectedVotes,
pledgeCoverage,
variant,
}: MunicipalityListExpectedVotesControlProps) => {
const scenarioContext = useMunicipalityEstimateScenarioOptional()
const activeScenario = scenarioContext?.scenario ?? DEFAULT_VOTE_ESTIMATE_SCENARIO
@ -45,6 +61,18 @@ export const MunicipalityListExpectedVotesControl = ({
const committedVotesRef = useRef(expectedVotes)
const lastPropsVotesRef = useRef(expectedVotes)
const saveGenerationRef = useRef(0)
const openRef = useRef(false)
/**
* Closing commits the draft and unmounts the Alert that would carry a
* failure, so a save that fails on the way out would silently revert the
* number. A toast outlives the overlay; while it is open the inline Alert is
* still the closer channel.
*/
const reportFailure = (message: string) => {
setErrorMessage(message)
if (!openRef.current) toast.error(message)
}
// Adopt server props only when they change from outside (navigation / RSC refresh).
useEffect(() => {
@ -91,7 +119,7 @@ export const MunicipalityListExpectedVotesControl = ({
if (!response.ok || payload.status !== 'success') {
setDisplayVotes(committedVotesRef.current)
setDraft(committedVotesRef.current)
setErrorMessage(payload.status === 'error' ? payload.message : SAVE_ERROR_MESSAGE)
reportFailure(payload.status === 'error' ? payload.message : SAVE_ERROR_MESSAGE)
return
}
@ -103,7 +131,7 @@ export const MunicipalityListExpectedVotesControl = ({
if (controller.signal.aborted || generation !== saveGenerationRef.current) return
setDisplayVotes(committedVotesRef.current)
setDraft(committedVotesRef.current)
setErrorMessage(SAVE_ERROR_MESSAGE)
reportFailure(SAVE_ERROR_MESSAGE)
} finally {
if (generation === saveGenerationRef.current) {
setIsPending(false)
@ -126,6 +154,7 @@ export const MunicipalityListExpectedVotesControl = ({
}
const handleOpenChange = (nextOpen: boolean) => {
openRef.current = nextOpen
if (nextOpen) {
setIsDirty(false)
setErrorMessage(null)
@ -147,62 +176,69 @@ export const MunicipalityListExpectedVotesControl = ({
: isDirty
? 'Alterações serão salvas automaticamente.'
: ''
// An `aria-label` replaces the trigger's content, and the content here is the
// number itself — so the label has to carry it, and name the scenario it
// belongs to (the picker can disagree with the column's sort, E9).
const activeValue = displayVotes[activeScenario]
const triggerLabel = [
'Editar votos estimados',
municipalityName ? ` em ${municipalityName}` : '',
` — ${voteEstimateScenarioLabels[activeScenario]}: `,
activeValue == null ? 'sem estimativa' : formatElectionNumber(activeValue),
].join('')
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<button
type="button"
aria-expanded={open}
aria-haspopup="dialog"
className={cn(
'group relative flex min-h-11 w-full items-center justify-center rounded-md px-1 hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
open ? 'bg-muted/60' : undefined,
)}
aria-label="Editar votos estimados"
>
<StaffMunicipalityVotesDisplay
expectedVotes={displayVotes}
pledgeCoverage={pledgeCoverage}
activeScenario={activeScenario}
layout="compact"
align="center"
suppressHoverPreview={open}
valueClassName="font-medium tabular-nums"
<CampaignCellEditOverlay
variant={variant}
open={open}
onOpenChange={handleOpenChange}
title="Editar votos estimados"
description={municipalityName}
triggerLabel={triggerLabel}
triggerBusy={isPending}
triggerClassName="group flex w-full items-center justify-center"
align="center"
contentClassName="w-[15.5rem] p-3"
preventPopoverAutoFocus
trigger={
<StaffMunicipalityVotesDisplay
expectedVotes={displayVotes}
pledgeCoverage={pledgeCoverage}
activeScenario={activeScenario}
layout="compact"
align="center"
suppressHoverPreview={open}
valueClassName="font-medium tabular-nums"
/>
}
>
<div className="relative flex flex-col gap-2.5">
{isPending ? (
<Spinner
className="absolute top-0 right-0 size-3.5 text-muted-foreground"
aria-label="Salvando votos estimados"
/>
</button>
</PopoverTrigger>
<PopoverContent
align="center"
sideOffset={8}
className="w-[15.5rem] p-3"
onOpenAutoFocus={(event) => event.preventDefault()}
>
<div className="relative flex flex-col gap-2.5">
{isPending ? (
<Spinner
className="absolute top-0 right-0 size-3.5 text-muted-foreground"
aria-label="Salvando votos estimados"
/>
) : null}
<VoteEstimateScenarioInputs
fieldPrefix="expectedVotes"
values={draft}
idPrefix={`municipality-list-expected-votes-${municipalityID}`}
variant="compact"
autoFocusScenario="central"
onValuesChange={handleValuesChange}
/>
{errorMessage ? (
<Alert variant="destructive" className="py-2">
<AlertDescription className="text-xs">{errorMessage}</AlertDescription>
</Alert>
) : null}
<p className="sr-only" aria-live="polite">
{statusMessage}
</p>
</div>
</PopoverContent>
</Popover>
) : null}
<VoteEstimateScenarioInputs
fieldPrefix="expectedVotes"
values={draft}
idPrefix={`municipality-list-expected-votes-${municipalityID}`}
variant="compact"
// Desktop: the popover is a transient overlay and typing is the
// point. On touch the Drawer would raise the virtual keyboard before
// the field is even read, so the first tap picks the scenario.
autoFocusScenario={variant === 'sheet' ? undefined : 'central'}
onValuesChange={handleValuesChange}
/>
{errorMessage ? (
<Alert variant="destructive" className="py-2">
<AlertDescription className="text-xs">{errorMessage}</AlertDescription>
</Alert>
) : null}
<p className="sr-only" aria-live="polite">
{statusMessage}
</p>
</div>
</CampaignCellEditOverlay>
)
}

View file

@ -18,18 +18,18 @@ import {
* pattern as `MunicipalityListExpectedVotesControl`) and shows the
* percent + deficit for that scenario, computed server-side for all three.
*
* `compact` (desktop table, dense) shows a short signed deficit with the
* full sentence as a `title` tooltip — same "abbreviate + title" pattern as
* `StaffMunicipalityVotesDisplay`'s "Nas lideranças" subline — so the column
* doesn't force a wide `whitespace-nowrap` cell. `default` (mobile cards,
* more room) spells the sentence out.
* The signed deficit is abbreviated so neither the desktop column forces a wide
* `whitespace-nowrap` cell nor the mobile card spends two lines on it (B42).
* The full sentence rides along twice: as a `title` for the mouse (same pattern
* as `StaffMunicipalityVotesDisplay`'s "Nas lideranças" subline) and as
* `sr-only` text, because `title` alone reaches neither a screen reader nor a
* touch device. Sighted touch users read the abbreviation under its `dt` label;
* the sentence itself is on the detail page's "Conta da cadeira" card.
*/
export const MunicipalityListGoalCoverageCell = ({
coverageByScenario,
layout = 'default',
}: {
coverageByScenario: Record<VoteEstimateScenario, MunicipalityGoalCoverage>
layout?: 'default' | 'compact'
}) => {
const scenarioContext = useMunicipalityEstimateScenarioOptional()
const activeScenario = scenarioContext?.scenario ?? DEFAULT_VOTE_ESTIMATE_SCENARIO
@ -45,18 +45,19 @@ export const MunicipalityListGoalCoverageCell = ({
<span className="font-medium tabular-nums" title={`Cenário ${scenarioLabel}`}>
{formatGoalCoverageRatioLabel(coverage)}
</span>
{layout === 'compact' ? (
<span
className="text-xs text-muted-foreground tabular-nums"
title={`${deficitLabel} (cenário ${scenarioLabel})`}
>
{formatGoalCoverageDeficitShortLabel(coverage)}
</span>
) : (
<span className="text-xs text-muted-foreground tabular-nums">
{deficitLabel} · cenário {scenarioLabel}
</span>
)}
<span
aria-hidden="true"
className="text-xs text-muted-foreground tabular-nums"
title={`${deficitLabel} (cenário ${scenarioLabel})`}
>
{formatGoalCoverageDeficitShortLabel(coverage)}
</span>
{/*
* `title` is a mouse-only channel — never announced reliably, never
* reachable by touch — so the abbreviation it explains is hidden from
* assistive tech and the sentence itself is what gets read.
*/}
<span className="sr-only">{`${deficitLabel} (cenário ${scenarioLabel})`}</span>
</div>
)
}

View file

@ -4,6 +4,10 @@ import { useActionState, useEffect, useId, useState, type ReactNode } from 'reac
import { toast } from 'sonner'
import { MunicipalitySignalFields } from '@/components/campaign/municipality/MunicipalitySignalFields'
import {
campaignCellEditTriggerClassName,
type CampaignCellEditOverlayVariant,
} from '@/components/campaign/shared/CampaignCellEditOverlay'
import { Alert, AlertDescription } from '@/components/ui/Alert'
import { Button } from '@/components/ui/button'
import {
@ -17,6 +21,7 @@ import {
} from '@/components/ui/Drawer'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover'
import { Spinner } from '@/components/ui/Spinner'
import { cn } from '@/lib/utils'
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
import {
formatMunicipalitySignalAgeLabel,
@ -33,7 +38,7 @@ type MunicipalityListSignalControlProps = {
municipalitySlug: string
municipalityName: string
lastSignalAt: string | null
variant: 'popover' | 'sheet'
variant: CampaignCellEditOverlayVariant
formAction: MunicipalityStaffFormAction
children: ReactNode
}
@ -97,8 +102,18 @@ export const MunicipalityListSignalControl = ({
setFormKey((key) => key + 1)
}, [state.message, state.status])
const triggerClassName =
'min-h-11 rounded-md px-1 text-left hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring'
// This control keeps its own container (its `<form>` has to wrap header, body
// and submit), so it borrows the shell's trigger instead of re-spelling it —
// `relative` included, which is what keeps the tap on the mobile card.
const triggerClassName = cn(campaignCellEditTriggerClassName, 'text-left')
const triggerProps = {
type: 'button',
'aria-expanded': open,
'aria-haspopup': 'dialog',
'aria-busy': isPending || undefined,
className: triggerClassName,
'aria-label': triggerLabel,
} as const
const fields = (
<SignalFormFields
@ -113,12 +128,7 @@ export const MunicipalityListSignalControl = ({
if (variant === 'sheet') {
return (
<>
<button
type="button"
className={triggerClassName}
aria-label={triggerLabel}
onClick={() => setOpen(true)}
>
<button {...triggerProps} onClick={() => setOpen(true)}>
{children}
</button>
<Drawer open={open} onOpenChange={setOpen}>
@ -149,9 +159,7 @@ export const MunicipalityListSignalControl = ({
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button type="button" className={triggerClassName} aria-label={triggerLabel}>
{children}
</button>
<button {...triggerProps}>{children}</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-80">
<form key={formKey} action={submitAction} className="flex flex-col gap-3">

View file

@ -1,21 +1,23 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { toast } from 'sonner'
import type {
MunicipalityListPoliticalTrendResponse,
MunicipalityListSavedPoliticalTrend,
} from '@/app/(campaign)/campanha/(app)/municipios/political-trend/types'
import { CampaignHoverTooltip } from '@/components/campaign/shared/CampaignHoverTooltip'
import {
CampaignCellEditOverlay,
type CampaignCellEditOverlayVariant,
} from '@/components/campaign/shared/CampaignCellEditOverlay'
import { Alert, AlertDescription } from '@/components/ui/Alert'
import { Badge } from '@/components/ui/Badge'
import { Field, FieldLabel } from '@/components/ui/field'
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover'
import { Spinner } from '@/components/ui/Spinner'
import { Textarea } from '@/components/ui/textarea'
import { parsePoliticalTrendStatusFormValue } from '@/lib/schemas/municipality'
import { cn } from '@/lib/utils'
import { politicalTrendBadgeVariant, politicalTrendLabels } from '@/utilities/municipalityLabels'
const NOTE_AUTOSAVE_MS = 600
@ -36,14 +38,18 @@ const trendsEqual = (
type MunicipalityListTrendControlProps = {
municipalityID: number
municipalityName: string
status: MunicipalityListSavedPoliticalTrend['status']
trendNote: string | null
variant: CampaignCellEditOverlayVariant
}
export const MunicipalityListTrendControl = ({
municipalityID,
municipalityName,
status,
trendNote,
variant,
}: MunicipalityListTrendControlProps) => {
const initialTrend: MunicipalityListSavedPoliticalTrend = {
status,
@ -63,6 +69,18 @@ export const MunicipalityListTrendControl = ({
const saveGenerationRef = useRef(0)
/** Skip abort+re-POST when blur and popover-close flush the same payload. */
const inFlightTrendRef = useRef<MunicipalityListSavedPoliticalTrend | null>(null)
const openRef = useRef(false)
/**
* Closing is what commits the draft, and closing also unmounts the Alert and
* the live region that would carry a failure — so a save that fails on the
* way out reverts the badge with nobody told. A toast outlives the overlay;
* while it is still open the inline Alert remains the closer channel.
*/
const reportFailure = (message: string) => {
setErrorMessage(message)
if (!openRef.current) toast.error(message)
}
const adoptTrend = (trend: MunicipalityListSavedPoliticalTrend) => {
setDisplayTrend(trend)
@ -128,7 +146,7 @@ export const MunicipalityListTrendControl = ({
if (!response.ok || payload.status !== 'success') {
adoptTrend(committedTrendRef.current)
setIsDirty(false)
setErrorMessage(payload.status === 'error' ? payload.message : SAVE_ERROR_MESSAGE)
reportFailure(payload.status === 'error' ? payload.message : SAVE_ERROR_MESSAGE)
return
}
@ -139,7 +157,7 @@ export const MunicipalityListTrendControl = ({
if (controller.signal.aborted || generation !== saveGenerationRef.current) return
adoptTrend(committedTrendRef.current)
setIsDirty(false)
setErrorMessage(SAVE_ERROR_MESSAGE)
reportFailure(SAVE_ERROR_MESSAGE)
} finally {
if (generation === saveGenerationRef.current) {
inFlightTrendRef.current = null
@ -196,6 +214,7 @@ export const MunicipalityListTrendControl = ({
}
const handleOpenChange = (nextOpen: boolean) => {
openRef.current = nextOpen
if (nextOpen) {
setIsDirty(false)
setErrorMessage(null)
@ -207,6 +226,9 @@ export const MunicipalityListTrendControl = ({
}
const hasNote = Boolean(displayTrend.note)
const trendLabel = displayTrend.status
? politicalTrendLabels[displayTrend.status]
: 'Não registrada'
const statusMessage = errorMessage
? errorMessage
: isPending
@ -216,91 +238,78 @@ export const MunicipalityListTrendControl = ({
: ''
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<CampaignHoverTooltip
content={hasNote ? <p className="whitespace-pre-wrap">{displayTrend.note}</p> : null}
align="start"
openOnTouch={false}
disabled={open}
>
<PopoverTrigger asChild>
<button
type="button"
aria-expanded={open}
aria-haspopup="dialog"
aria-busy={isPending || undefined}
className={cn(
'min-h-11 rounded-md px-1 hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
open ? 'bg-muted/60' : undefined,
)}
aria-label="Editar tendência política"
<CampaignCellEditOverlay
variant={variant}
open={open}
onOpenChange={handleOpenChange}
title="Editar tendência"
description={municipalityName}
// The label names the current reading: an `aria-label` replaces the
// badge's own text, so without it the trigger would announce the verb and
// swallow the value every sighted user reads off the pill. Same shape as
// `MunicipalityListSignalControl`'s.
triggerLabel={`Editar tendência política em ${municipalityName} — ${trendLabel}`}
triggerBusy={isPending}
tooltipContent={hasNote ? <p className="whitespace-pre-wrap">{displayTrend.note}</p> : null}
contentClassName="w-72 p-3"
preventPopoverAutoFocus
trigger={
displayTrend.status ? (
<Badge variant={politicalTrendBadgeVariant[displayTrend.status]}>{trendLabel}</Badge>
) : (
<Badge variant="outline">{trendLabel}</Badge>
)
}
>
<div className="relative flex flex-col gap-3">
{isPending ? (
<Spinner
className="absolute top-0 right-0 size-3.5 text-muted-foreground"
aria-label="Salvando tendência"
/>
) : null}
<Field>
<FieldLabel htmlFor={`municipality-list-trend-${municipalityID}`}>Tendência</FieldLabel>
<NativeSelect
id={`municipality-list-trend-${municipalityID}`}
value={draft.status ?? ''}
onChange={(event) => handleStatusChange(event.target.value)}
className="min-h-11 w-full"
>
{displayTrend.status ? (
<Badge variant={politicalTrendBadgeVariant[displayTrend.status]}>
{politicalTrendLabels[displayTrend.status]}
</Badge>
) : (
<Badge variant="outline">Não registrada</Badge>
<NativeSelectOption value="">Não registrada</NativeSelectOption>
{(Object.keys(politicalTrendLabels) as Array<keyof typeof politicalTrendLabels>).map(
(trendStatus) => (
<NativeSelectOption key={trendStatus} value={trendStatus}>
{politicalTrendLabels[trendStatus]}
</NativeSelectOption>
),
)}
</button>
</PopoverTrigger>
</CampaignHoverTooltip>
<PopoverContent
align="start"
sideOffset={8}
className="w-72 p-3"
onOpenAutoFocus={(event) => event.preventDefault()}
>
<div className="relative flex flex-col gap-3">
{isPending ? (
<Spinner
className="absolute top-0 right-0 size-3.5 text-muted-foreground"
aria-label="Salvando tendência"
/>
) : null}
<Field>
<FieldLabel htmlFor={`municipality-list-trend-${municipalityID}`}>Tendência</FieldLabel>
<NativeSelect
id={`municipality-list-trend-${municipalityID}`}
value={draft.status ?? ''}
onChange={(event) => handleStatusChange(event.target.value)}
className="min-h-11 w-full"
>
<NativeSelectOption value="">Não registrada</NativeSelectOption>
{(Object.keys(politicalTrendLabels) as Array<keyof typeof politicalTrendLabels>).map(
(trendStatus) => (
<NativeSelectOption key={trendStatus} value={trendStatus}>
{politicalTrendLabels[trendStatus]}
</NativeSelectOption>
),
)}
</NativeSelect>
</Field>
<Field>
<FieldLabel htmlFor={`municipality-list-trend-note-${municipalityID}`}>
Justificativa
</FieldLabel>
<Textarea
id={`municipality-list-trend-note-${municipalityID}`}
value={noteDraft}
onChange={(event) => handleNoteChange(event.target.value)}
onBlur={flushDraft}
maxLength={NOTE_MAX_LENGTH}
rows={3}
className="min-h-20 resize-y"
placeholder="Por que essa leitura?"
/>
</Field>
{errorMessage ? (
<Alert variant="destructive" className="py-2">
<AlertDescription className="text-xs">{errorMessage}</AlertDescription>
</Alert>
) : null}
<p className="sr-only" aria-live="polite">
{statusMessage}
</p>
</div>
</PopoverContent>
</Popover>
</NativeSelect>
</Field>
<Field>
<FieldLabel htmlFor={`municipality-list-trend-note-${municipalityID}`}>
Justificativa
</FieldLabel>
<Textarea
id={`municipality-list-trend-note-${municipalityID}`}
value={noteDraft}
onChange={(event) => handleNoteChange(event.target.value)}
onBlur={flushDraft}
maxLength={NOTE_MAX_LENGTH}
rows={3}
className="min-h-20 resize-y"
placeholder="Por que essa leitura?"
/>
</Field>
{errorMessage ? (
<Alert variant="destructive" className="py-2">
<AlertDescription className="text-xs">{errorMessage}</AlertDescription>
</Alert>
) : null}
<p className="sr-only" aria-live="polite">
{statusMessage}
</p>
</div>
</CampaignCellEditOverlay>
)
}

View file

@ -0,0 +1,182 @@
'use client'
import { useRef, type ReactNode } from 'react'
import { CampaignHoverTooltip } from '@/components/campaign/shared/CampaignHoverTooltip'
import { Button } from '@/components/ui/button'
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
} from '@/components/ui/Drawer'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover'
import { cn } from '@/lib/utils'
export type CampaignCellEditOverlayVariant = 'popover' | 'sheet'
/**
* Shared so the triggers this container does NOT own — `MunicipalityListSignalControl`,
* which wraps its own `<form>` — can't drift from the ones it does.
* `relative` is load-bearing: see the comment where it is composed below.
*/
export const campaignCellEditTriggerClassName =
'relative min-h-11 rounded-md px-1 hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring'
type CampaignCellEditOverlayProps = {
variant: CampaignCellEditOverlayVariant
open: boolean
onOpenChange: (open: boolean) => void
/** Dialog title: the Drawer's heading, and the Popover's accessible name. */
title: string
trigger: ReactNode
triggerLabel: string
triggerClassName?: string
triggerBusy?: boolean
children: ReactNode
// Popover only ─────────────────────────────────────────────────────────────
/** B23's read-without-opening tooltip: there is no hover on touch. */
tooltipContent?: ReactNode
align?: 'start' | 'center' | 'end'
contentClassName?: string
/**
* Leaves focus on the trigger so the control's own fields decide what to
* focus. The Drawer never needs it — it always opens with focus on its title,
* never on a field (see `initialFocus` below).
*/
preventPopoverAutoFocus?: boolean
// Sheet only ───────────────────────────────────────────────────────────────
/** Subject line under the title: which row is being edited. */
description?: string
sheetBodyClassName?: string
}
/**
* Container for the list's in-cell quick-edit surfaces: a Popover on `md+`, a
* bottom Drawer below it, where a Popover competes with the virtual keyboard
* (B42; `MunicipalityListSignalControl` set the precedent in B26).
*
* The variant comes from the call site, not from `matchMedia`: the lists render
* the mobile cards (`md:hidden`) and the table (`hidden md:block`) as sibling
* trees, so each already knows which viewport it belongs to — and a media query
* here would flip the container after hydration.
*
* Only the container lives here. Each control keeps its own auto-save scaffold
* (debounce, abort, flush on close), which is what `onOpenChange` carries — so
* closing the Drawer commits exactly like dismissing the Popover does.
*/
export const CampaignCellEditOverlay = ({
variant,
open,
onOpenChange,
title,
description,
trigger,
triggerLabel,
triggerClassName,
triggerBusy,
tooltipContent,
align = 'start',
contentClassName,
sheetBodyClassName,
preventPopoverAutoFocus,
children,
}: CampaignCellEditOverlayProps) => {
const titleRef = useRef<HTMLHeadingElement | null>(null)
const triggerButton = (
<button
type="button"
aria-expanded={open}
aria-haspopup="dialog"
aria-busy={triggerBusy || undefined}
// `relative` (in the shared class) is load-bearing on the mobile card,
// where the município's own link stretches an `after:inset-0` overlay
// across the whole card: it is what paints this trigger above that overlay
// and keeps its tap. It belongs on the control and not on the cell around
// it — a positioned cell would lift its label and padding too, turning
// them into taps that neither edit nor navigate.
className={cn(
campaignCellEditTriggerClassName,
open ? 'bg-muted/60' : undefined,
triggerClassName,
)}
aria-label={triggerLabel}
// The Popover's own trigger owns the click; the Drawer has none to borrow.
onClick={variant === 'sheet' ? () => onOpenChange(true) : undefined}
>
{trigger}
</button>
)
if (variant === 'sheet') {
return (
<>
{triggerButton}
<Drawer open={open} onOpenChange={onOpenChange}>
{/*
* Focus lands on the title, never on the first field: base-ui's
* default (first tabbable element) raises the virtual keyboard over
* the sheet before it can be read, and every one of these sheets is
* opened to look at a value at least as often as to type one. The
* title is focusable only programmatically (`tabIndex={-1}`), so it
* adds no stop to the tab order.
*/}
<DrawerContent initialFocus={titleRef}>
<DrawerHeader>
<DrawerTitle ref={titleRef} tabIndex={-1} className="outline-none">
{title}
</DrawerTitle>
{/* Guarded, not required: every sheet today names its row, but one
opened from under a heading that already does would pass none. */}
{description ? <DrawerDescription>{description}</DrawerDescription> : null}
</DrawerHeader>
<div
className={cn(
'flex min-h-0 flex-1 flex-col overflow-y-auto px-4 pt-3 pb-2',
sheetBodyClassName,
)}
>
{children}
</div>
<DrawerFooter>
<DrawerClose
render={<Button type="button" variant="outline" className="min-h-11 w-full" />}
>
Fechar
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
</>
)
}
return (
<Popover open={open} onOpenChange={onOpenChange}>
<CampaignHoverTooltip
content={tooltipContent}
align={align}
openOnTouch={false}
disabled={open}
>
<PopoverTrigger asChild>{triggerButton}</PopoverTrigger>
</CampaignHoverTooltip>
<PopoverContent
align={align}
sideOffset={8}
className={contentClassName}
// Radix gives the popover `role="dialog"` and no name; the Drawer's
// title is the same sentence, so it serves as one here too.
aria-label={title}
onOpenAutoFocus={preventPopoverAutoFocus ? (event) => event.preventDefault() : undefined}
>
{children}
</PopoverContent>
</Popover>
)
}

View file

@ -46,8 +46,11 @@ const valuesFromDrafts = (
optimistic: parseScenarioInput(drafts.optimistic),
})
// Three inputs side by side in a 15.5rem overlay: dense enough for a mouse at
// `md+`, where this only ever renders inside a Popover, and 44px below it,
// where B42 renders it inside a Drawer meant for thumbs.
const compactInputClassName =
'h-9 min-h-9 rounded-md px-1.5 text-center tabular-nums [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none'
'h-11 min-h-11 rounded-md px-1.5 text-center tabular-nums [appearance:textfield] md:h-9 md:min-h-9 [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none'
type VoteEstimateScenarioInputsProps = {
fieldPrefix: 'estimatedVotes' | 'expectedVotes'

View file

@ -433,3 +433,59 @@ test.describe('Municípios — jornadas por papel', () => {
await expect(page.getByText('Aprovada', { exact: true })).toBeVisible()
})
})
/**
* B42: on the phone the list is cards, the quick-edit cells are Drawers, and the
* card itself is the way into the município — the "Abrir município" button is
* gone. The two gestures that can only fail together are pinned here: tapping a
* control must not navigate, and tapping anything else must.
*/
test.describe('Municípios — cards no celular (B42)', () => {
test.use({ viewport: { width: 390, height: 844 } })
test('opens the trend Drawer without navigating, and the card body opens the município', async ({
campaign,
page,
}) => {
const { fixtures } = campaign
const password = fixtures.value('senha')
const coordinator = await campaign.payload.create({
collection: 'campaignUser',
data: {
name: fixtures.value('Coordenador Mobile'),
email: `${fixtures.value('coordinator-mobile')}@example.com`,
password,
role: 'coordinator',
},
depth: 0,
})
const municipality = await fixtures.claimMunicipality()
await campaign.login(page, coordinator.email!, password)
const listURL = `${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(municipality.name)}`
await page.goto(listURL)
const card = page.locator('[data-view="mobile-cards"] article').first()
await expect(card).toBeVisible()
await expect(page.getByRole('link', { name: 'Abrir município' })).toHaveCount(0)
await card.getByRole('button', { name: 'Editar tendência política' }).click()
const drawer = page.locator('[data-slot="drawer-popup"]')
await expect(drawer).toBeVisible()
await expect(drawer.getByText(municipality.name, { exact: true })).toBeVisible()
await expect(page).toHaveURL(listURL)
await drawer.getByRole('button', { name: 'Fechar' }).click()
await expect(drawer).toBeHidden()
// Anywhere outside a control is the município's own link: the click lands on
// the stretched `after:inset-0` overlay, which belongs to the card heading.
// The tap goes on a field LABEL inside the metrics grid on purpose — the
// first version of this card positioned each grid cell instead of each
// control, which lifted the labels and their padding above the overlay and
// made half the card a tap that neither edited nor navigated.
await card.getByText('Tendência', { exact: true }).click()
await expect(page).toHaveURL(`${campaign.baseURL}/campanha/municipios/${municipality.slug}`)
await expect(page.getByRole('heading', { name: municipality.name })).toBeVisible()
})
})

View file

@ -0,0 +1,143 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { createElement, type ReactElement } from 'react'
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import { MunicipalityListAdvisorsControl } from '@/components/campaign/municipality/MunicipalityListAdvisorsControl'
import { MunicipalityListExpectedVotesControl } from '@/components/campaign/municipality/MunicipalityListExpectedVotesControl'
import { MunicipalityListTrendControl } from '@/components/campaign/municipality/MunicipalityListTrendControl'
import type { CampaignCellEditOverlayVariant } from '@/components/campaign/shared/CampaignCellEditOverlay'
import { toVoteEstimateScenarioViewModel } from '@/lib/voteEstimate'
/**
* B42: the three auto-save quick-edit cells of `/campanha/municipios` share one
* container (`CampaignCellEditOverlay`) that is a Popover in the desktop table
* and a bottom Drawer in the mobile card. These cases pin the part a call site
* can silently get wrong — which container it renders and whether the touch
* surface says which município is being edited — for all three at once.
*/
const MUNICIPALITY_NAME = 'Feira de Santana'
type OverlayCase = {
name: string
/**
* The accessible name of the trigger. It replaces the pill / avatar stack /
* number it wraps, so it has to carry that value — each case spells the value
* out, which is the part a refactor drops without noticing.
*/
triggerLabel: string
/** Text the Drawer header must carry beyond the município name. */
drawerTitle: string
element: (variant: CampaignCellEditOverlayVariant) => ReactElement
}
const overlayCases: OverlayCase[] = [
{
name: 'tendência',
triggerLabel: `Editar tendência política em ${MUNICIPALITY_NAME} — Favorável`,
drawerTitle: 'Editar tendência',
element: (variant) =>
createElement(MunicipalityListTrendControl, {
municipalityID: 1,
municipalityName: MUNICIPALITY_NAME,
status: 'favoravel',
trendNote: 'Vereador migrou para a base',
variant,
}),
},
{
name: 'assessores',
triggerLabel: `Editar assessores em ${MUNICIPALITY_NAME} — Ana Bastos`,
drawerTitle: 'Atribuir assessores',
element: (variant) =>
createElement(MunicipalityListAdvisorsControl, {
municipalityID: 1,
municipalityName: MUNICIPALITY_NAME,
currentAdvisorIDs: [7],
isPriority: false,
advisorNamesById: new Map([[7, { id: 7, name: 'Ana Bastos', phone: null }]]),
options: [{ id: 7, name: 'Ana Bastos', isCurrent: false }],
variant,
}),
},
{
name: 'votos estimados',
triggerLabel: `Editar votos estimados em ${MUNICIPALITY_NAME} — Média: 1.200`,
drawerTitle: 'Editar votos estimados',
element: (variant) =>
createElement(MunicipalityListExpectedVotesControl, {
municipalityID: 1,
municipalityName: MUNICIPALITY_NAME,
expectedVotes: toVoteEstimateScenarioViewModel({ central: 1200 }),
pledgeCoverage: null,
variant,
}),
},
]
beforeAll(() => {
// The advisors control renders `cmdk`, which measures its list and keeps the
// selected item in view. jsdom implements neither. Same `ResizeObserver` stub
// as `campaignCompositionCleanup.unit.spec.ts`.
vi.stubGlobal(
'ResizeObserver',
class {
observe() {}
unobserve() {}
disconnect() {}
},
)
Element.prototype.scrollIntoView = () => {}
})
afterEach(cleanup)
afterAll(() => {
vi.unstubAllGlobals()
Reflect.deleteProperty(Element.prototype, 'scrollIntoView')
})
describe('campaign cell edit overlay', () => {
it.each(overlayCases)(
'opens $name in a Drawer that names the município on touch',
async ({ triggerLabel, drawerTitle, element }) => {
render(element('sheet'))
fireEvent.click(screen.getByRole('button', { name: triggerLabel }))
const dialog = await screen.findByRole('dialog')
const title = dialog.querySelector('[data-slot="drawer-title"]')
expect(title?.textContent).toBe(drawerTitle)
expect(dialog.querySelector('[data-slot="drawer-description"]')?.textContent).toBe(
MUNICIPALITY_NAME,
)
// Focus lands on the title, never on a field: on touch the alternative is
// the virtual keyboard covering the sheet before it can be read. Awaited
// because the advisors sheet mounts `cmdk`, which delays it by a tick.
await waitFor(() => expect(document.activeElement).toBe(title))
// Swiping down is the gesture, but it is not an affordance anyone sees.
expect(dialog.textContent).toContain('Fechar')
expect(document.querySelector('[data-slot="popover-content"]')).toBeNull()
},
)
it.each(overlayCases)(
'keeps $name in a Popover on the desktop table',
async ({ triggerLabel, element }) => {
render(element('popover'))
const trigger = screen.getByRole('button', { name: triggerLabel })
expect(trigger.getAttribute('aria-haspopup')).toBe('dialog')
fireEvent.click(trigger)
await screen.findByRole('dialog')
expect(document.querySelector('[data-slot="popover-content"]')).not.toBeNull()
expect(document.querySelector('[data-slot="drawer-popup"]')).toBeNull()
// Header and subject line are Drawer chrome. A Popover is anchored to the
// trigger it came from, and that trigger's accessible name already carries
// both the município and the current value.
expect(document.querySelector('[data-slot="drawer-title"]')).toBeNull()
expect(document.querySelector('[data-slot="drawer-description"]')).toBeNull()
},
)
})

View file

@ -247,9 +247,10 @@ describe('campaign visual foundation', () => {
expect(html).toContain('80%')
expect(html).toContain('Faltam 300 votos para a meta')
// 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, tbodyHtml = ''] = html.split('<tbody')
// pinned inside `<thead>` itself, since `cellTooltip` (e.g. "Classe") and
// the mobile card's own class tooltip (B42) add triggers of their own.
const [, tbodyHtml = ''] = html.split('<tbody')
const theadHtml = html.slice(html.indexOf('<thead'), html.indexOf('</thead>'))
expect(theadHtml.match(/data-slot="tooltip-trigger"/g)).toHaveLength(10)
// B41: horizontal scroll + sticky Município (Territory pattern).
@ -575,10 +576,12 @@ describe('campaign visual foundation', () => {
const withAdvisors = renderToStaticMarkup(
createElement(MunicipalityListAdvisorsControl, {
municipalityID: 1,
municipalityName: 'Feira de Santana',
currentAdvisorIDs: [7],
isPriority: false,
advisorNamesById: new Map([[7, { id: 7, name: 'Ana Bastos', phone: null }]]),
options: [],
variant: 'popover',
}),
)
expect(withAdvisors.match(/data-slot="tooltip-trigger"/g)).toHaveLength(1)
@ -591,10 +594,12 @@ describe('campaign visual foundation', () => {
const withoutAdvisors = renderToStaticMarkup(
createElement(MunicipalityListAdvisorsControl, {
municipalityID: 1,
municipalityName: 'Feira de Santana',
currentAdvisorIDs: [],
isPriority: false,
advisorNamesById: new Map(),
options: [],
variant: 'popover',
}),
)
expect(withoutAdvisors).not.toContain('data-slot="tooltip-trigger"')
@ -606,8 +611,10 @@ describe('campaign visual foundation', () => {
const withNote = renderToStaticMarkup(
createElement(MunicipalityListTrendControl, {
municipalityID: 1,
municipalityName: 'Feira de Santana',
status: 'favoravel',
trendNote: 'Vereador migrou para a base',
variant: 'popover',
}),
)
expect(withNote.match(/data-slot="tooltip-trigger"/g)).toHaveLength(1)
@ -620,8 +627,10 @@ describe('campaign visual foundation', () => {
const withoutNote = renderToStaticMarkup(
createElement(MunicipalityListTrendControl, {
municipalityID: 1,
municipalityName: 'Feira de Santana',
status: 'favoravel',
trendNote: null,
variant: 'popover',
}),
)
expect(withoutNote).not.toContain('data-slot="tooltip-trigger"')