feat(campaign): Tendência migra para Ações ao colapsar a coluna (B172)
Em /campanha/municipios (staff), abaixo de 60rem do container a coluna Tendência some inteira (hidden @min-60:table-cell no th+td, invertendo a decisão B158 'container query nunca duplica' só para Tendência) e o mesmo editor monta como ícone compacto na coluna Ações, ao lado do de atualização; acima de 60rem volta o badge completo e Ações é gated fechado quando nada está oculto manualmente. Ações torna-se sempre montada para staff e pinada por ocultação manual no B17. Uma instância visível do editor por linha por faixa (dual-render CSS-gated, sem JS/resize). Sem migration. Closes #446.
This commit is contained in:
parent
de2e807ee0
commit
07b4509e42
7 changed files with 247 additions and 62 deletions
|
|
@ -22,7 +22,8 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
|
|||
|
||||
**Recently resolved (2026-08-09):** **C94** (Issue #438) — barra da agenda consolidada: os 3 seletores (Município/Tag/Deputado presente) viraram **um combobox omnibox** (`activityAgendaOmnibox.ts` + chassis B127) com chips removíveis/“Limpar” e sugestões agrupadas; desktop toolbar `[combobox][carrinho giro][+ nova]` (ícones; mobile sem botão de criação no corpo — tudo no FAB); “Link de import” **saiu do card de filtros** para o **header do app** (`CampaignDesktopHeader`, **agenda-contextual**, ordem `[link][sino][IA]` do gate) via registro chaveado `SetCampaignHeaderAction` no `CampaignPageChromeContext` (slot compartilhado com C95), e virou ação de drawer no FAB mobile (`CampaignQuickAction.onAction` + `openCalendarFeed` no `CampaignQuickActionContext`) abrindo o **mesmo diálogo como sheet** responsivo (`CalendarFeedDialog` Dialog/Drawer; sem recorte mostra aviso “Aplique filtros…”, C93 cobre gerar sem filtro). O overlay do FAB fecha ao disparar ação de dialog. Sem migration/access/Consent. Intenção [`c94-agenda-combobox-header-acoes-rapidas.md`](docs/plans/c94-agenda-combobox-header-acoes-rapidas.md); impl [`c94-agenda-combobox-header-acoes-rapidas-impl.md`](docs/plans/c94-agenda-combobox-header-acoes-rapidas-impl.md).
|
||||
|
||||
(feat(C94): toolbar omnibox + e2e agenda hardening + changelog)
|
||||
**Recently resolved (2026-08-08):** **B172** (Issue #446) — na lista staff `/campanha/municipios`, abaixo da faixa C2 (< 60rem do container) a coluna **Tendência deixa de colapsar para ícone no próprio lugar** (inverte a decisão B158 "container query nunca duplica" **só para Tendência**): a coluna inteira some (`hidden @min-60:table-cell` no th+td) e o **mesmo** editor de tendência (status+justificativa, autosave) passa a morar como ícone compacto na coluna **Ações**, ao lado do de atualização; acima de 60rem volta o badge completo e Ações é gated fechado (`@min-60:hidden`) quando nada está oculto manualmente. `Ações` tornou-se **sempre** montada para staff (antes só por ocultação manual) e pinada quando `trend`/`lastSignal` está oculto no B17. Uma instância **visível** do editor por linha por faixa (dual-render CSS-gated, sem JS/resize — a leitura "uma instância" do B158 virou "uma visível por faixa"). Atualização/Sinal fica de fora (opção A). Side effect documentado: sort/filtro de tendência via header some abaixo de 60rem (seguem no omnibox). Sem migration/dados/Consent. **Bug de produção corrigido no caminho (React 19.2.4 flight):** o `className` novo na coluna destravou um bug do serializer do RSC — com `votos` oculta o thead virou uma tarefa de ~4,7 KB (acima do `MAX_ROW_SIZE=3200`) e o span do cabeçalho `Dobradinha` (com `description`) foi deferido para chunk posterior; no SSR o `CampaignHoverTooltip` recebia o placeholder não-resolvido como `children` → `cloneElement(undefined)` → React #130 derrubando o painel (e2e `campaignColumnPicker` vermelho 3/3 em prod, verde em dev). Correção: `CampaignTableHead` passou a enviar o rótulo como prop de dados (`explanationLabel`) em vez de elemento `children` — string nunca é deferida; o tooltip reconstrói o trigger. Sem mudança visual; os demais call sites do tooltip inalterados. Intenção [`tendencia-icone-coluna-acoes.md`](docs/plans/tendencia-icone-coluna-acoes.md); impl [`tendencia-icone-coluna-acoes-impl.md`](docs/plans/tendencia-icone-coluna-acoes-impl.md).
|
||||
|
||||
**Recently resolved (2026-08-08):** **`pnpm worktree plan [bag] [--go] [--no-migrate]`** — worktree de **planejamento** para rodar a skill `/plan-issue` sem ocupar o main: **cada invocação cria UM DIFERENTE**, para sessões de planejamento paralelas — com `bag`, branch `plans/plan-issue-<bag>` (sufixo `-2`/`-3` se o nome já estiver vivo); sem `bag`, o próximo sequencial `plans/plan-issue-<n>` livre (`PLAN_BRANCH_PREFIX`/`planBranchName`, `scripts/lib/worktree.mjs`). Prefixo minúsculo `plans/…` nunca colide com um `next` posterior em branch nem em slot (slot do plano = hash do branch, registrado via marcador e bumpado pelos vivos). Mesmo provisionamento isolado do `next` (porta `3100+slot`, bancos `teqo_wt<slot>`/`teqo_wt<slot>_test`, migrations; sem Docker degrada ao compartilhado). `kill` cobre; comment das envs distingue `gerado por pnpm worktree plan`. Sem migration. Naming unit-testada.
|
||||
|
||||
**Recently resolved (2026-08-08):** **B168** (Issue #422) — desfeito o **encadeamento automático** das ações rápidas do wizard (B98 era o precedente que este item inverte; evidência de campo: o coordenador rejeitou a tela avançando sozinha). Ao salvar **qualquer** ação de ajuste (votos, tendência, atualização, liderança), o usuário volta à origem (`from` allowlist B110 mantido; Início sem `from`) com toast — nunca avança para outro wizard. Removidos a fila/pipeline `wizardActionChain.ts` (fundido `wizardReturnHref` + `wizardPreviousHref` só com passos internos em `campaignActionRoutes.ts`), o param `entry`, os skip resolvers/`Pular` (`WizardSkipTrailing`/`WizardTrendSkipTrailing` deletados, chrome mobile sempre “X”) e o prefill de nota sem produtor (`notePrefill`/`voteFrom`/`voteTo`). Tendência: pós-save o RSC refresha com status já aplicado e o **client** é o único dono da navegação (toast + retorno) — o redirect servidor engolia o toast (B97 corrigido no caminho). Liderança: botão flutuante pós-save passou de “Continuar” (avançava) para **“Concluir”** (retorna à origem). Sem migration. Intenção [`desfazer-encadeamento-acoes-wizard.md`](plans/desfazer-encadeamento-acoes-wizard.md); impl [`desfazer-encadeamento-acoes-wizard-impl.md`](plans/desfazer-encadeamento-acoes-wizard-impl.md).
|
||||
|
|
|
|||
84
docs/plans/tendencia-icone-coluna-acoes-impl.md
Normal file
84
docs/plans/tendencia-icone-coluna-acoes-impl.md
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
# Impl: B172 — Lista de municípios: ao colapsar, coluna de Tendência desaparece e o ícone vai para Ações
|
||||
|
||||
Status: aprovado
|
||||
Atualizado em: 2026-08-08
|
||||
Issue: #446
|
||||
Intenção: docs/plans/tendencia-icone-coluna-acoes.md
|
||||
Appetite restante: ~0,5–1 dia eng (herdado); uma superfície tocada (`MunicipalityList.tsx` + testes)
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** abaixo de 60rem (ponto em que o badge de Tendência colapsaria), a coluna `Tendência` não é renderizada e o editor de tendência aparece como ícone compacto na coluna `Ações`, ao lado do ícone de atualização. Acima, a coluna mostra o badge completo e `Ações` não ganha cópia. Uma instância visível do editor por linha em qualquer largura. Preferência do picker B17 preservada.
|
||||
- **O que NÃO negociar:** um editor por linha por faixa de largura (sem double autosave/popover); ocultação manual do B17 continua valendo quando há espaço; sem JS de resize — container queries como B158; `Atualização` fora de escopo (questão 1 resolvida como **opção A** — só `Tendência` migra; a coluna `Sinal` continua adaptativa no próprio lugar).
|
||||
- **O que reavaliar:** a hipótese da intenção apontava seam `triggerPresentation` no controle. A inspeção mostra que isso não basta: o trigger não pode ser **movido entre `<td>`s** com CSS — display só alterna no lugar. A migração exige uma segunda instância do controle (CSS-gated), o que muda a leitura de "uma instância" (vira uma por faixa, não uma no DOM).
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[coluna trend] -->|class hidden @min-60:table-cell| B[escondida < 60rem]
|
||||
A -->|>= 60rem| C[badge completo]
|
||||
D[coluna actions] -->|class @min-60:hidden se nenhuma ocultação manual| E[< 60rem: ícone trend migrado]
|
||||
D -->|trendIsHidden/signalIsHidden -> sempre visível| F[ícones pinados]
|
||||
```
|
||||
|
||||
**Opções consideradas:** A) dual-render do controle + gating por container query na coluna; B) drop da coluna `trend` e trigger só em `Ações`; C) mover o controle único via JS/portal.
|
||||
**Recomendação: A** — é a única que mantém o aceite completo (badge na coluna em largura + ícone em `Ações` em estreito) com CSS puro, zero JS de resize, coerente com a mecânica B158.
|
||||
**Rejeitadas:** B porque em tela larga o usuário perde o readout de tendência na linha (aceite exige badge na coluna); C porque relocar uma instância única entre células de tabela exige `ResizeObserver`/mutation das `<td>` — o anti-goal da intenção (sem JS de resize) e a arquitetura "server decide, CSS aparenta" do B158.
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **`municipalityListColumns`** (`src/components/campaign/municipality/MunicipalityList.tsx`):
|
||||
- `responsiveColumnClassName` ganha `trend: 'hidden @min-[60rem]/municipality-list:table-cell'`; aplicado no `head` (via `MunicipalitySortableHead className`) e `cellClassName` da coluna `trend`. O renderer da célula mantém `renderTrendControl(municipality, 'adaptive')` **inalterado**: acima de 60rem o adaptive mostra o badge completo; a coluna some abaixo — zero mudança na fiação do controle.
|
||||
- Coluna `actions`: deixa de ser condicionada a `trendIsHidden || signalIsHidden` e passa a **sempre** ser montada para staff (tem de ser — ela é a casa do fallback de largura mesmo com `Tendência` visível). `const actionsPinned = trendIsHidden || signalIsHidden`; `head`/`cellClassName` recebem `actionsPinned ? undefined : '@min-[60rem]/municipality-list:hidden'` (visível só abaixo de 60rem quando nada está oculto manualmente).
|
||||
- Célula de `actions`: trecho de tendência vira
|
||||
`trendIsHidden ? renderTrendControl(m, 'compact') : <span className="@min-[60rem]/municipality-list:hidden">{renderTrendControl(m, 'compact')}</span>`
|
||||
(ícone pinado quando oculto; gated por container quando visível). Trecho de sinal **inalterado** (`signalIsHidden ? … : null`, opção A).
|
||||
- A união local `MunicipalityTableColumnId` já inclui `'actions'` — nada a mudar.
|
||||
- **`MunicipalityListTrendControl.tsx`:** sem mudança. O seam `'compact'`/`'adaptive'` já existe (51963c... — usados hoje para `actions` e coluna).
|
||||
- **Migration:** nenhuma. **Access/Consent:** nenhum. **UI:** Impeccable B — encaixe em tela existente, sem shape/craft além da movimentação do trigger; validação visual no browser (Playwright + snapshot perceptual do contrato abaixo/em/acima).
|
||||
|
||||
### Matriz de largura × visibilidade (contrato do e2e)
|
||||
|
||||
| Container | trends visível no picker | trend oculto |
|
||||
| ---------- | ----------------------------------------------------------------------------------------- | -------------------------------------- |
|
||||
| `< 60rem` | coluna `Tendência` some · `Ações` visível com ícone trend (+ sinal se oculto manualmente) | `Ações` visível com ícone trend pinado |
|
||||
| `>= 60rem` | coluna `Tendência` com badge completo · `Ações` `display:none` (não-frame) | `Ações` visível com ícone trend pinado |
|
||||
|
||||
Quando `Sinal` está oculto manualmente, `Ações` é sempre visível (pinado) e o ícone de tendência, se a coluna estiver visível, fica gated por `@min-[60rem]:hidden` dentro dela.
|
||||
|
||||
> **Side effect assumido (documentado no gate de review):** abaixo de 60rem o `<th>` da coluna `Tendência` sai de cena (`display:none`), então o **sort por Tendência e o filtro de tendência via header** ficam inacessíveis nessa faixa (pre-B172 eles existiam como ícone no próprio lugar). O sort/filtro continua disponível pelo omnibox (mesmas seeds de `municipalityOmnibox.ts`); o trade-off é o objetivo do item (coluna sumir em vez de colapsar). Não é regressão: se a coluna não existe, suas affordances de header não existem.
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Server/render** — editar `MunicipalityList.tsx` (classes da coluna `trend` + lógica de `actions`); `pnpm gate:fast` (lint + typecheck + unit).
|
||||
2. **Testes** — atualizar `tests/unit/campaignComponents.unit.spec.ts` (matriz `hasActions` e contagem de labels) e `tests/e2e/campaignMunicipalityResponsiveColumns.e2e.spec.ts` (`expectedHeadersAt`); rodar unit.
|
||||
3. **Browser/E2E** — `pnpm test:unit`; e2e dirigido `tests/e2e/campaignMunicipalityResponsiveColumns.e2e.spec.ts` (dev server 3272 + `teqo_wt172_test`); `pnpm test:int` (sem impacto esperado, confirma verde); `pnpm check:cycles`, `knip`, `format:check`.
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- Reorganizar `Ações`/picker/outros controles (corte do item e da intenção).
|
||||
- Aplicar a mesma regra a `Sinal`/`Atualização` (opção B da intenção — item sucessor).
|
||||
- Mudar o breakpoint 60rem (contrato comportamental de B158 C2; calibrar só com evidência `scrollWidth`, como B158 explicitava).
|
||||
- `CampaignTable`/shell genérico: não criar seam compartilhado — 1 call site, `cellClassName` local basta (depth check).
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **Duas instâncias montadas por linha (coluna + `Ações`), uma visível por faixa:** custo de memória/estado de autosave 2× por linha em todas as larguras. Mitigação: tabela paginada e excluída a linha quando `trend` está oculto (a instância da coluna nem existe); aceita-se como o preço de CSS puro — e é a mesma dualidade que o adaptive (compact/full spans) B158 já monta. O teste bloqueia que as duas apareçam na mesma faixa (e2e de headers + primeiro trigger visível).
|
||||
- **Regressão de contagem de labels nos unit:** os testes de "uma instância" precisam reapontar o contrato de `hidden: []` e `hidden: ['lastSignal']` (agora 2 labels de tendência no markup). Atualização explícita na fase 2.
|
||||
- **e2e B158 `expectedHeadersAt`:** agora `Tendência` só acima de 60rem e `Ações` só abaixo — atualizar a tabela de expects. O teste de sidebar/Sollinha usa só o `.length` (sem nomes), e a aritmética de contagem atravessa 60rem com saldo 0 em ambos os cenários (antes: `Tendência` sempre; agora troca `Ações`↔`Tendência`), então não muda o comportamento monotônico dele.
|
||||
- **GRID `Ações` vazia:** nunca há `Ações` vazia visível — toda configuração que a mostra tem ao menos um ícone (trend fallback ou pinado, ou sinal pinado).
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [x] Aceite de produto da intenção ainda coberto (única instância visível por faixa, B17 preservado, `Atualização` fora).
|
||||
- [x] Invariantes AGENTS/engineering-standards (sem migration/Consent/URL; CSS-only; sem `window`/`ResizeObserver`).
|
||||
- [x] Testes de domínio previstos: unit de markup (classes simétricas e contagem de triggers) + e2e de faixas de container (`expectedHeadersAt`), sem testar preset de viewport.
|
||||
|
||||
## Bug de produção encontrado e corrigido (React 19.2.4 flight)
|
||||
|
||||
O `className` novo na coluna `Tendência` (`MunicipalitySortableHead`) destravou um bug do **React 19.2.4** no serializer do RSC flight, que o e2e `campaignColumnPicker` pegou (3/3 vermelho em prod, verde em dev):
|
||||
|
||||
- **Sintoma:** `Minified React error #130 — Element type is invalid: ... got: undefined`, derrubando o painel inteiro no SSR quando uma coluna era ocultada pelo picker B17 (o repro mínimo: cookie `municipios:votos` + load direto).
|
||||
- **Causa-raiz:** o flight serializer emite um elemento passado como `children` de um Client Component como referência lazy (`$L<id>`) quando a tarefa servidor cruza o orçamento `MAX_ROW_SIZE = 3200` de `serializedSize`. Com `votos` visível, a presença da coluna forçava a divisão do thead em tarefas por coluna (o span de `Dobradinha` serializava inline na tarefa própria); com `votos` oculta, o thead virou UMA tarefa de ~4,7 KB e o span de `Dobradinha` (cabeçalho `CampaignTableHead` com `description`) caiu exatamente na fronteira → deferido para chunk posterior → no SSR o `CampaignHoverTooltip` recebia o placeholder não-resolvido como `children` → `cloneElement(undefined)` → #130. O `className` de B172 foi o bytes-extra que moveu a fronteira para cima do span.
|
||||
- **Correção (2 arquivos, sem mudança visual):** `CampaignTableHead` passa o rótulo do cabeçalho como prop de dados (`explanationLabel`) em vez de elemento `children` — string/ReactNode nunca é deferida pelo flight; o `CampaignHoverTooltip` reconstrói o trigger (`<span tabIndex={0} ...>`) a partir do label. Todos os outros call sites do tooltip (células, cards, sortable heads) seguem no caminho `children` original, inalterado. O e2e `campaignColumnPicker` passou a ser verde 3/3 e o `campaignMunicipalityResponsiveColumns` 4/4 em produção; a coluna `Dobradinha` continua exibindo o cabeçalho após a correção (verificado no DOM).
|
||||
|
|
@ -169,11 +169,15 @@ type MunicipalityColumn = CampaignTableColumn<MunicipalityListViewModel> & {
|
|||
|
||||
const responsiveColumnClassName = {
|
||||
advisors: 'hidden @min-[54rem]/municipality-list:table-cell',
|
||||
trend: 'hidden @min-[60rem]/municipality-list:table-cell',
|
||||
leaderships: 'hidden @min-[66rem]/municipality-list:table-cell',
|
||||
stateDeputies: 'hidden @min-[72rem]/municipality-list:table-cell',
|
||||
goalCoverage: 'hidden @min-[78rem]/municipality-list:table-cell',
|
||||
} as const satisfies Partial<Record<MunicipalityTableColumnId, string>>
|
||||
|
||||
/** B172 — `actions` only exists below 60rem, while trend's own column is gone. */
|
||||
const actionsWidthGateClass = '@min-[60rem]/municipality-list:hidden'
|
||||
|
||||
const MunicipalityListSignalTrigger = ({
|
||||
lastSignalAt,
|
||||
presentation,
|
||||
|
|
@ -225,6 +229,10 @@ const municipalityListColumns = ({
|
|||
const manuallyHidden = isStaffView ? new Set(columnVisibility.hiddenColumnIds) : new Set<string>()
|
||||
const trendIsHidden = manuallyHidden.has('trend')
|
||||
const signalIsHidden = manuallyHidden.has('lastSignal')
|
||||
// B172 — while the picker still shows trend, the `actions` column is gated
|
||||
// closed by the same 60rem breakpoint that hides trend's own column (one
|
||||
// visible editor per row per width); a manual hide pins the column open.
|
||||
const actionsAlwaysVisible = trendIsHidden || signalIsHidden
|
||||
|
||||
const renderTrendControl = (
|
||||
municipality: MunicipalityListViewModel,
|
||||
|
|
@ -499,8 +507,10 @@ const municipalityListColumns = ({
|
|||
sortKey="trend"
|
||||
filterParam="trend"
|
||||
description={municipalityColumnDescriptions.trend}
|
||||
className={responsiveColumnClassName.trend}
|
||||
/>
|
||||
),
|
||||
cellClassName: responsiveColumnClassName.trend,
|
||||
cell: (municipality) => renderTrendCell(municipality, 'adaptive'),
|
||||
},
|
||||
{
|
||||
|
|
@ -595,27 +605,42 @@ const municipalityListColumns = ({
|
|||
),
|
||||
cell: (municipality) => renderSignalCell(municipality, 'adaptive'),
|
||||
},
|
||||
...(trendIsHidden || signalIsHidden
|
||||
? ([
|
||||
{
|
||||
id: 'actions',
|
||||
label: 'Ações',
|
||||
mandatory: true,
|
||||
head: (
|
||||
<CampaignTableHead align="right">
|
||||
<span className="sr-only">Ações</span>
|
||||
</CampaignTableHead>
|
||||
),
|
||||
cellClassName: 'text-right',
|
||||
cell: (municipality) => (
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{trendIsHidden ? renderTrendCell(municipality, 'compact') : null}
|
||||
{signalIsHidden ? renderSignalCell(municipality, 'compact') : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
] satisfies Array<MunicipalityColumn>)
|
||||
: []),
|
||||
{
|
||||
// B172 — below 60rem the trend column disappears
|
||||
// (`responsiveColumnClassName.trend`) and the same editor is mounted
|
||||
// here as a compact trigger. The column is always mounted so the
|
||||
// width fallback has a home when the picker shows trend; it is gated
|
||||
// closed above 60rem unless a manual hide pins it open.
|
||||
id: 'actions',
|
||||
label: 'Ações',
|
||||
mandatory: true,
|
||||
head: (
|
||||
<CampaignTableHead
|
||||
align="right"
|
||||
className={actionsAlwaysVisible ? undefined : actionsWidthGateClass}
|
||||
>
|
||||
<span className="sr-only">Ações</span>
|
||||
</CampaignTableHead>
|
||||
),
|
||||
cellClassName: cn(
|
||||
'text-right',
|
||||
actionsAlwaysVisible ? undefined : actionsWidthGateClass,
|
||||
),
|
||||
cell: (municipality) => (
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{/* The compact trend trigger replaces its own column on a manual
|
||||
hide or only backs it up below 60rem — never both at once. */}
|
||||
{trendIsHidden ? (
|
||||
renderTrendCell(municipality, 'compact')
|
||||
) : (
|
||||
<span className={actionsWidthGateClass}>
|
||||
{renderTrendCell(municipality, 'compact')}
|
||||
</span>
|
||||
)}
|
||||
{signalIsHidden ? renderSignalCell(municipality, 'compact') : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
] satisfies Array<MunicipalityColumn>)
|
||||
: ([
|
||||
{
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import type { PointerEvent, ReactElement, ReactNode, Ref } from 'react'
|
|||
import { cloneElement, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { campaignHoverExplanationClassName } from '@/lib/campaignHoverTooltip'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/**
|
||||
* Hover/focus explanation with no extra chrome — wraps an existing control,
|
||||
|
|
@ -44,6 +46,8 @@ type TappableElement = ReactElement<{
|
|||
export const CampaignHoverTooltip = ({
|
||||
content,
|
||||
children,
|
||||
explanationLabel,
|
||||
filterAffordance = false,
|
||||
side = 'bottom',
|
||||
align = 'center',
|
||||
sideOffset = 4,
|
||||
|
|
@ -51,7 +55,19 @@ export const CampaignHoverTooltip = ({
|
|||
disabled = false,
|
||||
}: {
|
||||
content: ReactNode
|
||||
children: TappableElement
|
||||
children?: TappableElement
|
||||
/**
|
||||
* `CampaignTableHead`'s label, passed as DATA instead of as the `children`
|
||||
* element. React 19.2.4's flight serializer defers an element passed as a
|
||||
* client component's `children` when the enclosing server task crosses its
|
||||
* row budget (MAX_ROW_SIZE) — during SSR the component then receives an
|
||||
* unresolved lazy placeholder instead of the element, and cloning it
|
||||
* produces "Element type is invalid: got undefined". A string/ReactNode
|
||||
* prop is never deferred, so the trigger is rebuilt client-side from it.
|
||||
*/
|
||||
explanationLabel?: ReactNode
|
||||
/** `CampaignTableHead`-only: keep the label's min-height when a filter sits beside it. */
|
||||
filterAffordance?: boolean
|
||||
side?: 'top' | 'right' | 'bottom' | 'left'
|
||||
align?: 'start' | 'center' | 'end'
|
||||
sideOffset?: number
|
||||
|
|
@ -79,17 +95,54 @@ export const CampaignHoverTooltip = ({
|
|||
return () => document.removeEventListener('pointerdown', closeIfOutside)
|
||||
}, [open])
|
||||
|
||||
if (content === null || content === undefined || content === false) return children
|
||||
if (content === null || content === undefined || content === false) {
|
||||
return explanationLabel !== undefined ? (
|
||||
<span
|
||||
tabIndex={0}
|
||||
className={cn(
|
||||
campaignHoverExplanationClassName,
|
||||
filterAffordance && 'inline-flex min-h-11 items-center',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
)}
|
||||
>
|
||||
{explanationLabel}
|
||||
</span>
|
||||
) : (
|
||||
children
|
||||
)
|
||||
}
|
||||
|
||||
const trigger = cloneElement(children, {
|
||||
ref: (node: HTMLElement | null) => {
|
||||
triggerRef.current = node
|
||||
},
|
||||
onPointerUp: (event: PointerEvent) => {
|
||||
children.props.onPointerUp?.(event)
|
||||
if (openOnTouch && event.pointerType === 'touch') setOpen((current) => !current)
|
||||
},
|
||||
})
|
||||
// `explanationLabel` callers rebuild the trigger from the data prop; the
|
||||
// shared path clones the caller's element.
|
||||
const trigger =
|
||||
explanationLabel !== undefined ? (
|
||||
<span
|
||||
tabIndex={0}
|
||||
className={cn(
|
||||
campaignHoverExplanationClassName,
|
||||
filterAffordance && 'inline-flex min-h-11 items-center',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
)}
|
||||
ref={(node) => {
|
||||
triggerRef.current = node
|
||||
}}
|
||||
onPointerUp={(event) => {
|
||||
if (openOnTouch && event.pointerType === 'touch') setOpen((current) => !current)
|
||||
}}
|
||||
>
|
||||
{explanationLabel}
|
||||
</span>
|
||||
) : (
|
||||
cloneElement(children as TappableElement, {
|
||||
ref: (node: HTMLElement | null) => {
|
||||
triggerRef.current = node
|
||||
},
|
||||
onPointerUp: (event: PointerEvent) => {
|
||||
;(children as TappableElement).props.onPointerUp?.(event)
|
||||
if (openOnTouch && event.pointerType === 'touch') setOpen((current) => !current)
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
return (
|
||||
<Tooltip open={!disabled && open} onOpenChange={(next) => setOpen(disabled ? false : next)}>
|
||||
|
|
|
|||
|
|
@ -15,10 +15,7 @@ import {
|
|||
resolveVisibleColumns,
|
||||
type CampaignColumnVisibility,
|
||||
} from '@/lib/campaignColumnVisibility'
|
||||
import {
|
||||
campaignHoverExplanationClassName,
|
||||
campaignHoverTooltipAlign,
|
||||
} from '@/lib/campaignHoverTooltip'
|
||||
import { campaignHoverTooltipAlign } from '@/lib/campaignHoverTooltip'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/**
|
||||
|
|
@ -91,18 +88,12 @@ export const CampaignTableHead = ({
|
|||
filter?: ReactNode
|
||||
}) => {
|
||||
const label = description ? (
|
||||
<CampaignHoverTooltip content={description} align={campaignHoverTooltipAlign(align)}>
|
||||
<span
|
||||
tabIndex={0}
|
||||
className={cn(
|
||||
campaignHoverExplanationClassName,
|
||||
filter && 'inline-flex min-h-11 items-center',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
</CampaignHoverTooltip>
|
||||
<CampaignHoverTooltip
|
||||
content={description}
|
||||
align={campaignHoverTooltipAlign(align)}
|
||||
explanationLabel={children}
|
||||
filterAffordance={Boolean(filter)}
|
||||
/>
|
||||
) : filter ? (
|
||||
<span className="inline-flex min-h-11 items-center">{children}</span>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -74,11 +74,15 @@ const expectedHeadersAt = (width: number) => {
|
|||
|
||||
const headers = ['Município', '2022', '2026', 'Nível', 'Classe']
|
||||
if (width >= 54 * REM_IN_PIXELS) headers.push('Assessores')
|
||||
headers.push('Tendência')
|
||||
// B172 — below 60rem the trend column disappears and its compact trigger
|
||||
// moves into Ações; above it, the column shows the full badge and Ações has
|
||||
// nothing to host (it is width-gated closed), so the two swap at 60rem.
|
||||
if (width >= 60 * REM_IN_PIXELS) headers.push('Tendência')
|
||||
if (width >= 66 * REM_IN_PIXELS) headers.push('Liderança')
|
||||
if (width >= 72 * REM_IN_PIXELS) headers.push('Dobradinha')
|
||||
if (width >= 78 * REM_IN_PIXELS) headers.push('Cobertura')
|
||||
headers.push('Atualização')
|
||||
if (width < 60 * REM_IN_PIXELS) headers.push('Ações')
|
||||
return headers
|
||||
}
|
||||
|
||||
|
|
@ -131,6 +135,15 @@ test.describe('B158 — colunas responsivas por largura do conteúdo', () => {
|
|||
expect(await visibleTriggerText(trendTrigger)).toBe(
|
||||
width >= 60 * REM_IN_PIXELS ? 'Não registrada' : '',
|
||||
)
|
||||
// B172 — below 60rem the column is gone and the compact trigger lives
|
||||
// in Ações instead; `getByRole` skips display:none, so exactly one
|
||||
// visible trend editor exists per row at every width.
|
||||
await expect(
|
||||
table
|
||||
.locator('tbody tr')
|
||||
.first()
|
||||
.getByRole('button', { name: /^Editar tendência política/ }),
|
||||
).toHaveCount(1)
|
||||
expect(await visibleTriggerText(signalTrigger)).toBe(
|
||||
width >= 84 * REM_IN_PIXELS ? 'Sem sinal' : '',
|
||||
)
|
||||
|
|
|
|||
|
|
@ -768,6 +768,7 @@ describe('campaign visual foundation', () => {
|
|||
|
||||
for (const className of [
|
||||
'hidden @min-[54rem]/municipality-list:table-cell',
|
||||
'hidden @min-[60rem]/municipality-list:table-cell',
|
||||
'hidden @min-[66rem]/municipality-list:table-cell',
|
||||
'hidden @min-[72rem]/municipality-list:table-cell',
|
||||
'hidden @min-[78rem]/municipality-list:table-cell',
|
||||
|
|
@ -785,19 +786,36 @@ describe('campaign visual foundation', () => {
|
|||
})
|
||||
|
||||
it.each([
|
||||
{ hidden: [], hasActions: false },
|
||||
{ hidden: ['trend'], hasActions: true },
|
||||
{ hidden: ['lastSignal'], hasActions: true },
|
||||
{ hidden: ['trend', 'lastSignal'], hasActions: true },
|
||||
])('mounts one trend and signal editor in the table for $hidden', ({ hidden, hasActions }) => {
|
||||
const html = renderResponsiveList(hidden)
|
||||
const tableHtml = html.slice(html.indexOf('<table'), html.indexOf('</table>'))
|
||||
const theadHtml = tableHtml.slice(tableHtml.indexOf('<thead'), tableHtml.indexOf('</thead>'))
|
||||
{ hidden: [], trendEditors: 2, actionsGated: true },
|
||||
{ hidden: ['trend'], trendEditors: 1, actionsGated: false },
|
||||
{ hidden: ['lastSignal'], trendEditors: 2, actionsGated: false },
|
||||
{ hidden: ['trend', 'lastSignal'], trendEditors: 1, actionsGated: false },
|
||||
])(
|
||||
// B172 — the trend editor is mounted once per placement (its own column
|
||||
// above 60rem, the compact backup in Ações below). The backup only
|
||||
// exists while the picker still shows the column, so the total is 2 for
|
||||
// the visible case and 1 for the manual-hide case; the signal editor
|
||||
// never gains a second placement (option A). `actions` is always mounted
|
||||
// for staff and CSS-gated by container width when nothing is hidden.
|
||||
'keeps one signal editor and $trendEditors trend placements for $hidden',
|
||||
({ hidden, trendEditors, actionsGated }) => {
|
||||
const html = renderResponsiveList(hidden)
|
||||
const tableHtml = html.slice(html.indexOf('<table'), html.indexOf('</table>'))
|
||||
const theadHtml = tableHtml.slice(
|
||||
tableHtml.indexOf('<thead'),
|
||||
tableHtml.indexOf('</thead>'),
|
||||
)
|
||||
|
||||
expect(tableHtml.match(/aria-label="Editar tendência política/g)).toHaveLength(1)
|
||||
expect(tableHtml.match(/aria-label="Registrar atualização em/g)).toHaveLength(1)
|
||||
expect(theadHtml.includes('Ações')).toBe(hasActions)
|
||||
})
|
||||
expect(tableHtml.match(/aria-label="Editar tendência política/g)).toHaveLength(trendEditors)
|
||||
expect(tableHtml.match(/aria-label="Registrar atualização em/g)).toHaveLength(1)
|
||||
expect(theadHtml.includes('Ações')).toBe(true)
|
||||
if (actionsGated) {
|
||||
expect(theadHtml).toContain('@min-[60rem]/municipality-list:hidden')
|
||||
} else {
|
||||
expect(theadHtml).not.toContain('@min-[60rem]/municipality-list:hidden')
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
it('keeps removed and internal columns out of the municipality picker', () => {
|
||||
const columns = municipalityListPickerColumns({
|
||||
|
|
|
|||
Loading…
Reference in a new issue