Merge branch 'cursor/46b02119'

This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-26 03:25:21 -03:00
commit 4b60e9f1aa
9 changed files with 265 additions and 42 deletions

View file

@ -7,6 +7,7 @@ alwaysApply: false
## Status
- **B23 entregue em código (2026-07-26):** tooltip de conteúdo nas células das listas — a mecânica genérica (`cellTooltip` em `CampaignTableColumn`, `CampaignCellTooltip`, `CampaignHoverTooltip` promovido para `shared/`) já tinha saído de graça no **E10 ✓**; este item fechou o que faltava: `openOnTouch`/`disabled` em `CampaignHoverTooltip` (default `true`/`false`, sem mudança nos 4 call sites pré-B23) para conviver com um Popover no mesmo gatilho sem roubar o tap nem sobreviver atrás dele — e, no `/simplify`, ganhou um terceiro comportamento: `content` falsy renderiza o `children` sem wrapper, então os dois consumidores abaixo não precisam mais de um ternário `content ? <CampaignHoverTooltip>… : children` cada; `formatAdvisorNamesTooltip` + `advisorEntriesFromIds` (dedupe do id→nome entre a lista e o controle) junto de `MunicipalityAdvisorAvatarStack.tsx`; e os dois consumidores de `/campanha/municipios` — célula "Assessores" (não-coordenador via `cellTooltip`; coordenador envolvendo o próprio `PopoverTrigger`, `openOnTouch={false}`/`disabled={open}`, nunca os dois ao mesmo tempo) e célula "Tendência" (tooltip só quando há `politicalTrendNote`; em toque a nota aparece como leitura dentro do próprio Popover, acima do select). Verificado manualmente em `/campanha/municipios` como coordenador e assessor: hover mostra nomes/justificativa, foco de teclado abre a mesma tooltip, clique/tap abre o Popover sem tooltip órfã atrás. Gate verde (tsc/lint/format/knip — P3 pré-existente/check:cycles/443 unit/387 int/build); Aikido 0 findings. Plano: [`tooltip-celulas-listas.md`](../../../docs/plans/tooltip-celulas-listas.md).
- **B22 entregue em código (2026-07-26):** explicação por coluna no header de `/campanha/municipios` — `description?: ReactNode` em `CampaignTableHead` (`shared/CampaignTable.tsx`) e rename `tooltip`→`description` em `MunicipalitySortableHead`, ambos com o mesmo sublinhado pontilhado no label e o mesmo `CampaignHoverTooltip` compartilhado (já promovido para `shared/` pelo **E10 ✓**) — sem componente novo. Auditoria pré-implementação achou o escopo já reduzido: o primitivo de tooltip e o `cellTooltip` genérico já existiam (E10), então só faltava a prop em `CampaignTableHead` e o rename. Novo helper `campaignConceptOneLiner(id)` ao lado de `campaignConceptHref` em `campaignIntelligenceConcepts.ts` — fonte única para as colunas cobertas pelo **E18 ✓** (`expectedVotes` lê `oneLiner('meta')`, `goalCoverage` lê `oneLiner('cobertura-da-meta')` + a nota de cenário). Novo `MunicipalityListColumnId` + `municipalityColumnDescriptions` (`Record` de 11 entradas) em `municipalityLabels.ts` — a coluna "Classe" mantém o texto mais longo (`CLASS_COLUMN_DESCRIPTION`, movido de `MunicipalityList.tsx`) em vez do `oneLiner` terso de `classe-territorial`, decisão assumida porque uma classificação de 5 vias não cabe numa frase. `MunicipalityList.tsx` perdeu os três `*Hint` locais e a intersecção de tipo extra em `municipalityListColumns()`. Testes: pin do `campaignConceptOneLiner`, `describe('CampaignTableHead description')` (sem prop = render idêntico; com prop = 1 trigger, conteúdo fora do markup do servidor, mesmo contrato do `cellTooltip`), e contagem de `data-slot="tooltip-trigger"` no `<thead>` (10 staff / 5 leader) em `campaignComponents.unit.spec.ts`. Gate verde (tsc, lint, format, check:cycles, 442 unit / 387 int, build); `knip` segue com o erro pré-existente ao carregar `payload.config.ts` (ledgerado P3, confirmado idêntico fora desta mudança); Aikido sem achado. Fora do escopo (mantido do plano): copy nas outras 5 listas, tooltip nos cards mobile, variante Popover no touch. Plano: [`explicacao-colunas-header-listas.md`](../../../docs/plans/explicacao-colunas-header-listas.md).
- **C13 entregue em código (2026-07-25):** "Plano de Ação" → **"Atividade"** em toda a vertical. Collection `actionPlan` → `activity` (`src/collections/Activity.ts`, labels "Atividade"/"Atividades", hooks internos renomeados), campo `campaignDemand.actionPlan` → `activity`, 7 utilities `actionPlan*` → `activity*`, `access/actionPlans.ts` → `access/activities.ts`, `lib/schemas/actionPlan.ts` → `activity.ts`, `actions/actionPlan.ts` → `activity.ts`, `components/campaign/actionPlan/` → `activity/` (13 componentes), rota `(app)/planos/` → `(app)/atividades/` (criação em `atividades/nova`, feminino como `demandas/nova`) e sidebar "Atividades". **Migration data-preserving escrita à mão** `20260725_213000_rename_action_plan_to_activity`: 4 tabelas, 3 enums, 2 colunas, 39 índices (4 pkeys inclusos), 14 FKs, 2 sequences — lista canônica tirada de `pg_indexes`/`pg_constraint`/`pg_type`/`pg_sequences` porque o snapshot do drizzle não conhece o índice parcial `action_plan_upcoming_start_at_idx` (escrito à mão no C8) e porque o gerador do Payload emitiria DROP+CREATE. O `.json` foi derivado do snapshot anterior por transformação textual (o `migrate:create` é interativo para renames) e validado pelo teste que importa: `pnpm migrate:create __probe` responde "No schema changes detected". **Bônus aprovado pelo usuário nesta sessão:** os **8 índices fósseis `%plaza%`** da remodelagem de 23/07 (que renomeou as colunas `plaza_id` mas não os índices, deixando banco e snapshot em desacordo) foram alinhados na mesma migration; `down()` os restaura, para não inventar um estado que nunca existiu. Decisões de vocabulário: sidebar **"Atividades"** (uma palavra só, a da entidade) e rótulos de campo alinhados — **"Tipo de atividade"**, **"Origem da atividade"**, **"Resultado da atividade"** — porque "ação" ao lado de "atividade" reintroduzia a tradução mental que o item existe para matar; valores de enum `kind`/`status`/`origin` **inalterados**. **Guard:** o describe "banned campaign terminology" de `codebaseConventions.unit.spec.ts` virou tabela de termos varrendo `src`+`tests`+`scripts` (migrations fora — história congelada), banindo os compostos `actionPlan`, a copy "plano de ação" e a rota velha, mas **não** `plan`/`plano` isolado (colidiria com os planos de assinatura da Fase 2 white-label); allowlist: o próprio spec, `scripts/generate-remodel-municipalities-migration.mjs` e o comentário histórico em `campaignMigrationReconciliation.int.spec.ts`. Estendê-lo a `tests/` derrubou 8 fósseis "Praça" em fixtures, limpos no mesmo PR. Gate verde (tsc, lint, format, knip, check:cycles, 46 unit / 48 int / 7 e2e, build); Aikido sem achado. **Deploy:** o `pnpm build` da Vercel aplica a migration enquanto o deployment antigo ainda serve — deployar fora do horário de mesa (mesmo perfil da remodelagem de 23/07). **Pós-`/simplify`:** a varredura mecânica comeu uma interpolação — `tests/unit/recentVisits.unit.spec.ts` ficou com `label: \`Município \`` para todas as entradas, e passava porque o label não era asserido (restaurado, com assertion nova para fechar a brecha, e os slugs `praca-` do fixture alinhados). O guard ganhou tolerância a espaço (`action[\s_-]?plan`), que era o buraco por onde `access/shared.ts` mantinha "action plan" em prosa; com isso a allowlist encolheu — o comentário de `campaignMigrationReconciliation.int.spec.ts` passou a citar `activity` e o arquivo saiu da exceção (uma spec que cresce não fica fora do guard por causa de um comentário). Deliberadamente **não** ampliei o padrão de "Praça" para a forma sem cedilha: cobraria três entradas novas de allowlist (nomes reais de organização na planilha, o teste de entradas legadas `kind: 'nucleus'`) por um fóssil já corrigido. Também: três resíduos do find-and-replace (banner `// Action activities`, `DOSSIER_*_PLAN_LIMIT`, "a activity" em dois comentários), concordância em pt-BR ("Criada por", "atividades apoiadas" minúsculo), o `??` impossível em `ActivityForm`, e as duas buscas de FK da migration agora usam `to_regclass` em vez de `::regclass` (que lança em vez de retornar NULL, contradizendo a invariante do próprio cabeçalho). A entrada morta `action_plan_municipality_idx` saiu da lista de índices da migration (nenhuma migration jamais criou esse nome — só os snapshots o afirmam, e com `push: false` nenhum banco pode carregá-lo), deixando `up()` e `down()` com os mesmos 48 pares; e o detalhe de organização parou de derivar o rótulo de status à mão e passou a reusar `ActivityStatusBadge`, com `OrganizationDetailViewModel.activities[].status` tipado como `ActivityStatus` em vez de `string` (a origem que forçava o re-narrowing no consumidor). Plano: [`renomear-plano-acao-para-atividade.md`](../../../docs/plans/renomear-plano-acao-para-atividade.md).
- **B26 entregue em código (2026-07-25):** registrar sinal a partir da célula "Último sinal" de `/campanha/municipios` — Popover (desktop) / Drawer bottom sheet (mobile) cria `municipalityUpdate` `kind: 'sinal'` (C12) sem sair da fila; submit explícito "Registrar sinal" (exceção do princípio 4); `revalidateMunicipalityListPaths`; sem migration. Plano: [`registrar-sinal-lista-municipios.md`](../../../docs/plans/registrar-sinal-lista-municipios.md).

View file

@ -1,12 +1,14 @@
# Tooltip de conteúdo nas células das listas
Status: rascunho
Atualizado em: 2026-07-25
Status: entregue
Atualizado em: 2026-07-26
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B, item **B23**)
Impeccable: B — encaixe nas células "Assessores" e "Tendência" já existentes de `/campanha/municipios`, com a capacidade promovida para o sistema de listas compartilhado (`CampaignTable`)
Appetite: ~0,75 dia eng; 1 prop em `CampaignTableColumn`, 1 wrapper na célula, 1 flag no primitivo de tooltip, 2 consumidores (Assessores nos dois papéis + justificativa da Tendência); sem migration
Appetite: ~0,4 dia eng (revisado 2026-07-26 — a capacidade genérica de `cellTooltip`/`CampaignHoverTooltip` já tinha sido entregue de graça pelo **E10**, ver nota abaixo; o que restava era só os dois consumidores + `openOnTouch`/`disabled`); orçamento original ~0,75d
Responsável: —
> **Nota de fechamento (2026-07-26):** quando este item foi implementado, `cellTooltip` em `CampaignTableColumn`, `CampaignCellTooltip` e a promoção de `MunicipalityHoverTooltip` → `shared/CampaignHoverTooltip` **já existiam no código**, entregues como efeito colateral do **E10** (2026-07-25, coluna "Classe"). O texto abaixo ("Contexto", parte de "Decisões travadas") foi escrito antes disso e ainda descreve essa promoção como trabalho deste item — histórico, não corrigido retroativamente. O que este item de fato implementou: `openOnTouch`/`disabled` em `CampaignHoverTooltip`, `formatAdvisorNamesTooltip`, e os dois consumidores (célula "Assessores" nos dois papéis + célula "Tendência" com a nota lida no Popover).
## Design (Impeccable)
Âncoras: `PRODUCT.md` (princípio 2 — clareza sob pressão; princípio 3 — edit where you see; anti-goal spreadsheet/data-grid) / `DESIGN.md` (register `product`, Field Desk) · tema `data-theme='campaign'` · shells existentes: `CampaignTable` (`src/components/campaign/shared/CampaignTable.tsx`), `MunicipalityHoverTooltip` → `CampaignHoverTooltip` (**B22**), `MunicipalityAdvisorAvatarStack`, `MunicipalityListAdvisorsControl`.
@ -124,6 +126,8 @@ Componentes:
- **Adotar `cellTooltip` em outras colunas/listas** (Território truncado, dobradinhas por município). Revisitar quando: alguém do time relatar em sessão/R6 não conseguir ler uma célula, ou quando uma coluna nova nascer com conteúdo abreviado.
- **Justificativa em tooltip nas outras superfícies de tendência** (`MunicipalityStrategyCard`, dossiê). Revisitar quando: alguma delas passar a esconder a nota por densidade — hoje ambas a exibem por extenso.
- **Afford­ance visual (sublinhado pontilhado) na célula.** Revisitar quando: a primeira coluna de **texto** adotar a capacidade, ou o critique do R6 registrar falha de descoberta.
- **Paridade de leitor de tela para a nota de tendência.** A célula de assessores expõe todos os nomes em `sr-only` independente de hover (`MunicipalityAdvisorAvatarStack`); a de tendência não tem equivalente — `trendNote` só existe dentro do `CampaignHoverTooltip`/`PopoverContent`, ambos lazy-mount (contrato pinado em `campaignComponents.unit.spec.ts`: `not.toContain(trendNote)` na renderização estática). Achado do `/simplify` pós-rebase (2026-07-26), não corrigido nesta entrega porque adicionar um `sr-only` ali quebraria esse contrato de propósito e pede decisão explícita se a paridade vale o "duplo anúncio" para leitor de tela. Revisitar quando: um relato de acessibilidade real aparecer, ou o critique do R6 flagar hover-only content de novo.
- **Wrapper compartilhado `Popover` + `CampaignHoverTooltip`.** `MunicipalityListAdvisorsControl` e `MunicipalityListTrendControl` repetem a mesma composição (`openOnTouch={false}`, `disabled={open}`, `align="start"`) — 2º call site, abaixo do piso de 3 para extrair (achado do `/simplify` pós-rebase, 2026-07-26). Revisitar quando: uma 3ª célula precisar da mesma combinação Popover+tooltip (checado nesta sessão: `MunicipalityListSignalControl`/B26 não precisa — o `aria-label` do gatilho já carrega o texto por extenso).
## Referências

File diff suppressed because one or more lines are too long

View file

@ -1,4 +1,5 @@
import { CircleAlertIcon } from 'lucide-react'
import type { ReactElement } from 'react'
import { Avatar, AvatarFallback } from '@/components/ui/Avatar'
import { Badge } from '@/components/ui/Badge'
@ -10,6 +11,16 @@ export type MunicipalityAdvisorAvatarEntry = {
name: string
}
/** Resolves advisor ids to `{id, name}` entries, dropping any without a name (deleted/unknown user). */
export const advisorEntriesFromIds = (
advisorIDs: number[],
advisorNamesById: ReadonlyMap<number, { id: number; name: string }>,
): MunicipalityAdvisorAvatarEntry[] =>
advisorIDs.flatMap((id) => {
const advisor = advisorNamesById.get(id)
return advisor ? [{ id: advisor.id, name: advisor.name }] : []
})
/**
* E9: a priority município with nobody answering for it is the queue's loudest
* row, so it reads "Sem responsável" in destructive — the same words the
@ -24,6 +35,18 @@ export const MissingAdvisorBadge = ({ isPriority }: { isPriority: boolean }) =>
</Badge>
)
/** One advisor name per line, for the tooltip — `null` when the list is empty (that's `MissingAdvisorBadge`'s job). */
export const formatAdvisorNamesTooltip = (
advisors: MunicipalityAdvisorAvatarEntry[],
): ReactElement | null =>
advisors.length === 0 ? null : (
<div className="flex flex-col">
{advisors.map((advisor) => (
<span key={advisor.id}>{advisor.name}</span>
))}
</div>
)
export const MunicipalityAdvisorAvatarStack = ({
advisors,
isPriority,

View file

@ -2,6 +2,8 @@ import { CircleAlertIcon, SearchXIcon } from 'lucide-react'
import Link from 'next/link'
import {
advisorEntriesFromIds,
formatAdvisorNamesTooltip,
MissingAdvisorBadge,
MunicipalityAdvisorAvatarStack,
} from '@/components/campaign/municipality/MunicipalityAdvisorAvatarStack'
@ -230,11 +232,7 @@ const MunicipalityListEmptyState = ({ state }: { state: MunicipalityListState })
const advisorEntries = (
municipality: MunicipalityListViewModel,
advisorNamesById: ReadonlyMap<number, MunicipalityAdvisorSummary>,
) =>
municipality.advisorIDs.flatMap((id) => {
const advisor = advisorNamesById.get(id)
return advisor ? [{ id: advisor.id, name: advisor.name }] : []
})
) => advisorEntriesFromIds(municipality.advisorIDs, advisorNamesById)
const advisorNames = (
municipality: MunicipalityListViewModel,
@ -387,6 +385,13 @@ const municipalityListColumns = ({
isPriority={municipality.priority === 'alta'}
/>
),
// The coordinator's cell already wraps its own Popover trigger in a
// tooltip (see `MunicipalityListAdvisorsControl`) — declaring it
// here too would double it up on the same gesture.
cellTooltip: (municipality) =>
isCoordinator
? null
: formatAdvisorNamesTooltip(advisorEntries(municipality, advisorNamesById)),
},
{
id: 'trend',

View file

@ -3,7 +3,12 @@
import { useActionState, useEffect, useState } from 'react'
import { toast } from 'sonner'
import { MunicipalityAdvisorAvatarStack } from '@/components/campaign/municipality/MunicipalityAdvisorAvatarStack'
import {
advisorEntriesFromIds,
formatAdvisorNamesTooltip,
MunicipalityAdvisorAvatarStack,
} from '@/components/campaign/municipality/MunicipalityAdvisorAvatarStack'
import { CampaignHoverTooltip } from '@/components/campaign/shared/CampaignHoverTooltip'
import { Alert, AlertDescription } from '@/components/ui/Alert'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/Checkbox'
@ -43,10 +48,8 @@ export const MunicipalityListAdvisorsControl = ({
const [state, submitAction, isPending] = useActionState(formAction, {})
const currentSet = new Set(currentAdvisorIDs)
const names = currentAdvisorIDs.flatMap((id) => {
const advisor = advisorNamesById.get(id)
return advisor ? [{ id: advisor.id, name: advisor.name }] : []
})
const names = advisorEntriesFromIds(currentAdvisorIDs, advisorNamesById)
const tooltipContent = formatAdvisorNamesTooltip(names)
useEffect(() => {
if (state.status !== 'success') return
@ -56,15 +59,25 @@ export const MunicipalityListAdvisorsControl = ({
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
className="min-h-11 rounded-md px-1 hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label="Editar assessores"
>
<MunicipalityAdvisorAvatarStack advisors={names} isPriority={isPriority} />
</button>
</PopoverTrigger>
{/* No advisors: the button already reads "Sem responsável"/"Sem assessor"
by extenso, so a tooltip would only echo it (`tooltipContent` is
null — see `CampaignHoverTooltip` for the no-op contract). */}
<CampaignHoverTooltip
content={tooltipContent}
align="start"
openOnTouch={false}
disabled={open}
>
<PopoverTrigger asChild>
<button
type="button"
className="min-h-11 rounded-md px-1 hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label="Editar assessores"
>
<MunicipalityAdvisorAvatarStack advisors={names} isPriority={isPriority} />
</button>
</PopoverTrigger>
</CampaignHoverTooltip>
<PopoverContent align="start" className="w-80">
<form action={submitAction} className="flex flex-col gap-3">
<input type="hidden" name="municipalityId" value={municipalityID} />

View file

@ -3,6 +3,7 @@
import { useActionState, useEffect, useState } from 'react'
import { toast } from 'sonner'
import { CampaignHoverTooltip } from '@/components/campaign/shared/CampaignHoverTooltip'
import { Alert, AlertDescription } from '@/components/ui/Alert'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
@ -41,28 +42,40 @@ export const MunicipalityListTrendControl = ({
setOpen(false)
}, [state.message, state.status])
const hasNote = Boolean(trendNote?.trim())
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
className="min-h-11 rounded-md px-1 hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label="Editar tendência política"
>
{status ? (
<Badge variant={politicalTrendBadgeVariant[status]}>
{politicalTrendLabels[status]}
</Badge>
) : (
<Badge variant="outline">Não registrada</Badge>
)}
</button>
</PopoverTrigger>
<CampaignHoverTooltip
content={hasNote ? <p className="whitespace-pre-wrap">{trendNote}</p> : null}
align="start"
openOnTouch={false}
disabled={open}
>
<PopoverTrigger asChild>
<button
type="button"
className="min-h-11 rounded-md px-1 hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label="Editar tendência política"
>
{status ? (
<Badge variant={politicalTrendBadgeVariant[status]}>
{politicalTrendLabels[status]}
</Badge>
) : (
<Badge variant="outline">Não registrada</Badge>
)}
</button>
</PopoverTrigger>
</CampaignHoverTooltip>
<PopoverContent align="start" className="w-72">
<form action={submitAction} className="flex flex-col gap-3">
<input type="hidden" name="municipalityId" value={municipalityID} />
<input type="hidden" name="municipalitySlug" value={municipalitySlug} />
<input type="hidden" name="trendNote" value={trendNote ?? ''} />
{hasNote ? (
<p className="whitespace-pre-wrap text-sm text-muted-foreground">{trendNote}</p>
) : null}
<Field>
<FieldLabel htmlFor={`municipality-list-trend-${municipalityID}`}>Tendência</FieldLabel>
<NativeSelect

View file

@ -25,6 +25,16 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp
* what keeps at most one of these tooltips open at a time. The content itself
* counts as "inside": it may hold a link (E18's "Saiba mais"), and closing on
* `pointerdown` would unmount that link before its click ever landed.
*
* `openOnTouch` and `disabled` (B23) exist for triggers that also open a
* Popover — the coordinator's advisor/trend quick-edit cells (B9/B19). A tap
* there must open the edit surface, not this tooltip (`openOnTouch={false}`),
* and once that Popover is open the tooltip must not linger behind it
* (`disabled={open}` from the caller). Neither prop changes behavior for the
* four pre-B23 call sites, which don't pass them.
*
* Falsy `content` renders `children` unwrapped (no tooltip, no `TooltipProvider`)
* so callers can wrap a trigger unconditionally instead of a `content ? <CampaignHoverTooltip>… : children` ternary.
*/
type TappableElement = ReactElement<{
onPointerUp?: (event: PointerEvent) => void
@ -51,17 +61,25 @@ export const CampaignHoverTooltip = ({
side = 'bottom',
align = 'center',
sideOffset = 4,
openOnTouch = true,
disabled = false,
}: {
content: ReactNode
children: TappableElement
side?: 'top' | 'right' | 'bottom' | 'left'
align?: 'start' | 'center' | 'end'
sideOffset?: number
openOnTouch?: boolean
disabled?: boolean
}) => {
const [open, setOpen] = useState(false)
const triggerRef = useRef<HTMLElement | null>(null)
const contentRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
if (disabled) setOpen(false)
}, [disabled])
useEffect(() => {
if (!open) return
const closeIfOutside = (event: globalThis.PointerEvent) => {
@ -75,19 +93,21 @@ export const CampaignHoverTooltip = ({
return () => document.removeEventListener('pointerdown', closeIfOutside)
}, [open])
if (content === null || content === undefined || content === false) return children
const trigger = cloneElement(children, {
ref: (node: HTMLElement | null) => {
triggerRef.current = node
},
onPointerUp: (event: PointerEvent) => {
children.props.onPointerUp?.(event)
if (event.pointerType === 'touch') setOpen((current) => !current)
if (openOnTouch && event.pointerType === 'touch') setOpen((current) => !current)
},
})
return (
<TooltipProvider delayDuration={300}>
<Tooltip open={open} onOpenChange={setOpen}>
<Tooltip open={!disabled && open} onOpenChange={(next) => setOpen(disabled ? false : next)}>
<TooltipTrigger asChild>{trigger}</TooltipTrigger>
<TooltipContent
ref={contentRef}

View file

@ -7,7 +7,10 @@ import { describe, expect, it, vi } from 'vitest'
import { AppRouterContext } from 'next/dist/shared/lib/app-router-context.shared-runtime'
import { SupportStatusBadge } from '@/components/campaign/leadership/SupportStatusBadge'
import { formatAdvisorNamesTooltip } from '@/components/campaign/municipality/MunicipalityAdvisorAvatarStack'
import { MunicipalityList } from '@/components/campaign/municipality/MunicipalityList'
import { MunicipalityListAdvisorsControl } from '@/components/campaign/municipality/MunicipalityListAdvisorsControl'
import { MunicipalityListTrendControl } from '@/components/campaign/municipality/MunicipalityListTrendControl'
import { TseZoneBadge } from '@/components/campaign/municipality/TseZoneBadge'
import { CampaignScopeBadge } from '@/components/campaign/shared/CampaignScopeBadge'
import { Progress } from '@/components/ui/Progress'
@ -481,4 +484,145 @@ describe('campaign visual foundation', () => {
const [theadHtml] = html.split('<tbody')
expect(theadHtml.match(/data-slot="tooltip-trigger"/g)).toHaveLength(5)
})
/**
* B23: the advisor stack only shows up to 3 initials (and initials
* collide), so the full names ride along as redundant text. Radix mounts
* tooltip content only once opened, so these pin the trigger half of the
* contract — the same shape `campaignTable.unit.spec.ts` pins generically.
*/
describe('B23 cell tooltips', () => {
it('formats one advisor name per line and returns null for an empty list', () => {
expect(formatAdvisorNamesTooltip([])).toBeNull()
const one = renderToStaticMarkup(formatAdvisorNamesTooltip([{ id: 1, name: 'Ana Bastos' }])!)
expect(one).toContain('Ana Bastos')
const three = renderToStaticMarkup(
formatAdvisorNamesTooltip([
{ id: 1, name: 'Ana Bastos' },
{ id: 2, name: 'Beto Lima' },
{ id: 3, name: 'Carla Dias' },
])!,
)
expect(three).toContain('Ana Bastos')
expect(three).toContain('Beto Lima')
expect(three).toContain('Carla Dias')
})
it('wraps the advisor cell in a tooltip only when there is a name to show', () => {
// The staff `<thead>` already carries 10 header-explanation tooltips
// (B22), regardless of row data. This baseline isolates the advisors
// CELL tooltip, the one this test actually exercises.
const HEADER_TOOLTIP_COUNT = 10
const baseMunicipality = {
id: 1,
name: 'Seabra',
slug: 'seabra',
kind: 'municipio' as const,
city: 'Seabra',
region: 'Chapada Diamantina',
ibgeCode: '2929800',
zoneNumber: null,
priority: 'normal' as const,
lastUpdateAt: null,
lastSignalAt: null,
expectedVotes: toVoteEstimateScenarioViewModel(null),
politicalTrendStatus: null,
politicalTrendNote: null,
pledges: createEmptyMunicipalityPledgeAggregate(),
votePosition2022: null,
territorialClass: 'sem_base' as const,
territorialClassFactors: [],
goalCoverageByScenario: createEmptyGoalCoverageByScenario(),
}
const withAdvisors = renderToStaticMarkup(
createElement(MunicipalityList, {
municipalities: [{ ...baseMunicipality, advisorIDs: [7] }],
advisorNamesById: new Map([[7, { id: 7, name: 'Ana Bastos', phone: null }]]),
isStaffView: true,
...municipalityListDefaultProps,
}),
)
expect(withAdvisors.match(/data-slot="tooltip-trigger"/g)).toHaveLength(
HEADER_TOOLTIP_COUNT + 1,
)
const withoutAdvisors = renderToStaticMarkup(
createElement(MunicipalityList, {
municipalities: [{ ...baseMunicipality, advisorIDs: [] }],
advisorNamesById: new Map<number, MunicipalityAdvisorSummary>(),
isStaffView: true,
...municipalityListDefaultProps,
}),
)
expect(withoutAdvisors.match(/data-slot="tooltip-trigger"/g)).toHaveLength(
HEADER_TOOLTIP_COUNT,
)
expect(withoutAdvisors).toContain('Sem assessor')
})
it("wraps the coordinator's advisor Popover trigger in a tooltip, never doubling the affordance", () => {
const withAdvisors = renderToStaticMarkup(
createElement(MunicipalityListAdvisorsControl, {
municipalityID: 1,
municipalitySlug: 'seabra',
currentAdvisorIDs: [7],
isPriority: false,
advisorNamesById: new Map([[7, { id: 7, name: 'Ana Bastos', phone: null }]]),
options: [],
formAction: noopListFormAction,
}),
)
expect(withAdvisors.match(/data-slot="tooltip-trigger"/g)).toHaveLength(1)
// The tooltip wraps the SAME trigger — nesting `asChild` composition
// means only the outer wrapper's `data-slot` survives on the button,
// but Popover's own trigger wiring (still the innermost primitive)
// stays intact and functional.
expect(withAdvisors).toContain('aria-haspopup="dialog"')
const withoutAdvisors = renderToStaticMarkup(
createElement(MunicipalityListAdvisorsControl, {
municipalityID: 1,
municipalitySlug: 'seabra',
currentAdvisorIDs: [],
isPriority: false,
advisorNamesById: new Map(),
options: [],
formAction: noopListFormAction,
}),
)
expect(withoutAdvisors).not.toContain('data-slot="tooltip-trigger"')
expect(withoutAdvisors).toContain('data-slot="popover-trigger"')
expect(withoutAdvisors).toContain('aria-haspopup="dialog"')
})
it('shows the trend justification on hover only when one was recorded', () => {
const withNote = renderToStaticMarkup(
createElement(MunicipalityListTrendControl, {
municipalityID: 1,
municipalitySlug: 'seabra',
status: 'favoravel',
trendNote: 'Vereador migrou para a base',
formAction: noopListFormAction,
}),
)
expect(withNote.match(/data-slot="tooltip-trigger"/g)).toHaveLength(1)
// Radix mounts tooltip content lazily — it never reaches the server
// markup, same contract as `campaignTable.unit.spec.ts`.
expect(withNote).not.toContain('Vereador migrou para a base')
const withoutNote = renderToStaticMarkup(
createElement(MunicipalityListTrendControl, {
municipalityID: 1,
municipalitySlug: 'seabra',
status: 'favoravel',
trendNote: null,
formAction: noopListFormAction,
}),
)
expect(withoutNote).not.toContain('data-slot="tooltip-trigger"')
})
})
})