mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
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:
parent
1eb8590e99
commit
59d89ce287
17 changed files with 942 additions and 396 deletions
File diff suppressed because one or more lines are too long
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)} (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
182
src/components/campaign/shared/CampaignCellEditOverlay.tsx
Normal file
182
src/components/campaign/shared/CampaignCellEditOverlay.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
143
tests/unit/campaignCellEditOverlay.unit.spec.ts
Normal file
143
tests/unit/campaignCellEditOverlay.unit.spec.ts
Normal 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()
|
||||
},
|
||||
)
|
||||
})
|
||||
|
|
@ -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"')
|
||||
|
|
|
|||
Loading…
Reference in a new issue