Merge pull request #549 from fsolla/B184-municipios-mobile-lista-sem-moldura-omnibox-sticky-x-de
B184 — Municípios mobile: lista sem moldura (omnibox sticky, X de limpar no input, salvar filtro no header, cards edge-to-edge)
This commit is contained in:
commit
3ca200e7b0
20 changed files with 467 additions and 40 deletions
|
|
@ -4,7 +4,7 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
|
|||
|
||||
---
|
||||
|
||||
**Recently resolved (2026-08-10):** **C108** (Issue #522) — sino de notificações: **abrir já lê** — `openCampaignNotifications` (action única) lista + marca tudo como lido em 1 round trip, substituindo `listCampaignNotifications` + `markAllCampaignNotificationsRead`; o badge zera na abertura sem clique e o botão "Marcar todas como lidas" deixa de existir. Painel limpo em qualquer tamanho: sem título "Notificações" visível (sr-only para a11y), sem contador, sem botão "Fechar"; desktop vira **modal central** (Dialog shadcn, X no canto superior direito, clique fora fecha — par responsivo Dialog/Drawer do C94 via `useIsMobile`); mobile segue bottom sheet com handle, safe-area e **skeleton de loading** (o painel não abre mais em branco durante o round trip). `markAllNotificationsRead` extraída para `src/utilities/notification/notificationList.ts` (owner-scoped com bypass documentado, idempotente, cap 200 mantido; int testado: marca só as próprias, deixa as do outro intactas); `readAt` saiu do contrato `NotificationListItem` (sem leitores após a remoção do destaque de não lida); copy de erro unificada em `CAMPAIGN_NOTIFICATION_LOAD_ERROR_MESSAGE`; `router.refresh()` re-sincroniza a instância irmã do outro header só quando algo foi marcado (race cross-tab documentado); foco visível nos itens e `role="alert"` no erro. P4-D atualizado no ledger — o batch sem transação agora dispara a cada abertura (self-healing). Testes: int (owner-scope + idempotência) e unit novos do bell (dialog limpo, refresh condicional, empty/error, sheet mobile). Sem migration, access, Consent ou schema. Intenção [`sino-notificacoes-leitura-auto-modal-desktop.md`](plans/sino-notificacoes-leitura-auto-modal-desktop.md); impl [`sino-notificacoes-leitura-auto-modal-desktop-impl.md`](plans/sino-notificacoes-leitura-auto-modal-desktop-impl.md).
|
||||
**Recently resolved (2026-08-10):** **B184** (Issue #514) — no mobile (<768px), o filtro de listas `/campanha` virou o **padrão sem moldura do chassis `CampaignListOmnibox` (B127) — todas as listas**: label oculta (`input` carrega o nome via `aria-label`), campo sem borda/sombra (`md:` restaura o desktop), **form do filtro sticky sob a top bar** com linha separadora edge-to-edge, "Limpar" vira **X circular dentro do input** (aparece com chips OU busca digitada; slot sempre renderizado com `invisible` para a largura não pular; hit target 44px + círculo visual 28px; live region própria "Busca e filtros limpos." — limpar só busca não navega e o pending region ficaria mudo para AT), botão texto "Limpar" desktop-only. **Por que sticky no `<form>`:** sticky não escapa do containing block (o pai) — a coluna do input dentro do wrapper curto do omnibox "grudava" ~12px e rolava junto (medido); o form é o único wrapper filho do `CampaignPageShell` (altura total). **Exceção agenda (C101, merge anterior):** a `/campanha/agenda` já tinha o strip sticky próprio (`activity-agenda-filter-strip`) — mantido; o chassis ainda a cobre no campo/X/Limpar. Em municípios, adicionalmente: **"Salvar filtro" vira icon button no header mobile** (slot C94/C95, `SaveMunicipalityFilterControl presentation='icon'`; popover de nomear intacto; nome de rename é o contrato 2.5.3) e **cards edge-to-edge sem borda** com uma linha separando (`border-b last:border-b-0`, `-mx-4`, gate `md:`). "Nova atualização" (atualizações) — único trailing visível no mobile — foi para o header (mesmo padrão); `campaignMobileHeaderIconClassName` extraída (3º consumidor). E2E novo em `campaignMunicipalities.e2e.spec.ts` (390×844): label oculta, borda 0, sticky, X (limpa chips+busca, foco volta sem reabrir popover), salvar→renomear no header, cards com separador, desktop invertido (1280). Débito registrado no caminho: **D7** (sticky dos headers da tabela desktop morto — wrapper `overflow-x-auto` é o scroll container; medido). Sem migration, access, Consent. Intenção [`municipios-mobile-sem-moldura.md`](plans/municipios-mobile-sem-moldura.md); impl [`municipios-mobile-sem-moldura-impl.md`](plans/municipios-mobile-sem-moldura-impl.md).
|
||||
|
||||
**Recently resolved (2026-08-10):** **B185** (Issue #524) — o Sollinha ganhou a tool **`getPendingLeaderships`** (`src/utilities/ai/tools/getPendingLeaderships.ts`): "quais lideranças ainda precisam ser abordadas" por **território de identidade** (grafia tolerante a acento — "Vale do Jiquiriça" resolve para a região canônica), **cidade** ("Salvador" = as 19 ZE juntas) ou **município** (aliases canônicos), com o **critério de pendência sempre declarado na resposta** (`'A abordar'/'Em disputa', ou 'Engajado' sem compromisso de votos no escopo consultado` — eixo do compromisso = presença de `votePledge` liderança×município no escopo; `negativo` é excluído na query, fail-closed), lista com nome/municípios/status/última atualização/**assessores responsáveis** (nomes resolvidos em query própria — nunca `depth` populando `campaignUser`), top N (default 20, max 50) com `total` e `truncado`, escopo omitido = escopo de acesso do usuário. Filtro **C3** `sem_assessor` (`advisors: { exists: false }`, precedente B176) e modo **C4** `municipios_sem_lideranca` (municípios do escopo sem nenhuma liderança). **Gate staff inline** no topo do `execute` (leader → `{ error: 'Leitura de dados de lideranças negada.' }` antes de qualquer query, mesmo shape da B180; 1 call site — sem helper); leituras todas `overrideAccess: false, user: ctx.user` (RBAC atual). Links via `buildCampaignLinks` existente (nada mudou lá); seção nova no system prompt manda declarar o critério. Unit tests novos (`pendingLeadershipsTool.unit.spec.ts`, 14) — gate fail-closed com payload intocado, critério por status+pledge, pledge fora do escopo não satisfaz (where `municipality in`), escopos com acento/Salvador/município/erro, `sem_assessor` no where, C4, truncamento, nomes de assessores; allowlist na convenção P3-D justificada (filtro de domínio sobre set resolvido ≠ escopo de actor). Sem migration, access change ou Consent. Intenção [`sollinha-liderancas-pendentes-abordagem.md`](plans/sollinha-liderancas-pendentes-abordagem.md); impl [`sollinha-liderancas-pendentes-abordagem-impl.md`](plans/sollinha-liderancas-pendentes-abordagem-impl.md).
|
||||
|
||||
|
|
|
|||
92
docs/plans/municipios-mobile-sem-moldura-impl.md
Normal file
92
docs/plans/municipios-mobile-sem-moldura-impl.md
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
# Impl: B184 — Omnibox mobile sem moldura (padrão de todas as listas) + cards edge-to-edge e salvar no header (municípios)
|
||||
|
||||
Status: entregue em código (2026-08-09)
|
||||
Atualizado em: 2026-08-10
|
||||
Issue: #514
|
||||
Intenção: docs/plans/municipios-mobile-sem-moldura.md
|
||||
Appetite restante: ~0,5–1 dia eng (herdado; abordagem dentro do envelope)
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** no celular, o chrome do filtro de listas some: label oculta, omnibox sem moldura e sticky sob a barra superior com linha separadora, limpar vira X circular dentro do input (com chips **ou** busca digitada). **Escopo ampliado por decisão do humano (2026-08-09):** o look bare/sticky é o **padrão mobile do chassis `CampaignListOmnibox` — todas as listas `/campanha`**, não só municípios. Em municípios, adicionalmente: "Salvar filtro" vira icon button no header (mobile) e os cards ficam sem borda, edge-to-edge, separados por linha.
|
||||
- **O que NÃO negociar:** desktop de todas as listas inalterado (label visível, box com borda, botão texto "Limpar", sem X); comportamento de filtragem (URL/navegação) intacto em todas as listas; chips continuam dentro da omnibox; fluxo do popover de salvar (nomear/renomear) intacto; cards edge-to-edge e salvar no header são municípios-only (outras listas não têm essa superfície/saved filters); sem dados/KPI novos.
|
||||
- **O que reavaliar:** a hipótese da intenção escopava a municípios; a decisão do humano tornou o chassis o dono do padrão — prop opt-in morre, o look bare vira o default < `md` do chassis, e os 11 call sites mudam juntos. Cards e header continuam nos arquivos de municípios.
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph Chassis["CampaignListOmnibox (padrão < md, todos os 11 call sites)"]
|
||||
L[Label: hidden md:block<br/>input ganha aria-label={label} sempre]
|
||||
B[Box: border-0 shadow-none md:border md:border-input md:shadow-xs]
|
||||
S[Coluna do input: sticky top-0 z-20 -mx-4 border-b bg-background px-4 py-2<br/>md:static md:mx-0 md:border-b-0 md:bg-transparent]
|
||||
X["X circular no input: onClearAll && (chips || query)<br/>aria-label='Limpar', md:hidden"]
|
||||
C["'Limpar' texto: hidden md:inline-flex"]
|
||||
end
|
||||
subgraph Municipio["Municípios (adicional)"]
|
||||
M2[SaveMunicipalityFilterControl no trailing: hidden md:inline-flex]
|
||||
H1["page.tsx → SetCampaignHeaderAction → SaveMunicipalityFilterControl presentation='icon' + md:hidden"]
|
||||
K1["Cards: gap-0 -mx-4 md:gap-4 md:mx-0; article border-b last:border-b-0 md:border md:rounded-xl"]
|
||||
end
|
||||
Chassis --> Municipio
|
||||
```
|
||||
|
||||
**Opções consideradas:**
|
||||
|
||||
- **A — Look bare como padrão do chassis (`CampaignListOmnibox`), sem prop.** Todos os 11 call sites herdam; desktop via variantes `md:`; nada muda nos adapters/URLs.
|
||||
- **B — Prop opt-in `mobileBare` (plano anterior), só municípios.**
|
||||
- **C — Cópia local do chassis para municípios.**
|
||||
|
||||
**Recomendação:** A — a decisão do humano fez do look o padrão; prop opt-in viraria fricção para toda lista futura. O chassis é o dono do concern (B127) e não há divergência entre listas: todas passam `onClearAll`, todas limpam busca + filtros no `kind: 'clear'` (verificado em todos os 11 runActions), nenhuma tem trailing que precise ficar na região sticky (picker e ações ficam abaixo, fora da sticky, e as ações desktop-only já são `hidden md:flex`). Desktop continua 100% no comportamento atual via variantes `md:`.
|
||||
|
||||
**Rejeitadas:** B porque o pedido agora é o padrão — cada lista futura teria que lembrar da prop; C porque duplica o contrato ARIA/combobox do chassis (o próprio plano da intenção proíbe "twin" do dono existente).
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **`CampaignListOmnibox`** (`src/components/campaign/shared/CampaignListOmnibox.tsx`) — sem prop nova; abaixo de `md`:
|
||||
- `label` → `hidden md:block` (o `<label>` some só visualmente); `input` ganha `aria-label={label}` **sempre** (nome acessível idêntico ao do label — desktop inalterado na prática; mobile sem label continua nomeado).
|
||||
- Box (PopoverAnchor div) → base `border-0 shadow-none` + `md:border md:border-input md:shadow-xs`; `rounded-lg`, `px-2 py-1.5`, `min-h-11` e o ring de foco (`focus-within:ring-3`) permanecem (a11y).
|
||||
- **A barra sticky mora no `<form role="search">` do caller** via constante compartilhada `campaignListOmniboxFormClassName` (exportada do chassis; precedente `campaignCellEditTriggerClassName`): `sticky top-0 z-20 -mx-4 border-b border-border bg-background px-4 py-2 md:static md:z-auto md:mx-0 md:border-b-0 md:bg-transparent md:px-0 md:py-0`. **Por quê no form e não na coluna do input:** um elemento `sticky` nunca escapa do seu containing block (o pai) — a coluna do input vive dentro do wrapper curto do omnibox (73px), então ela "grudaria" só pela própria altura e rolaria junto (medido no browser: posição computada `sticky`, comportamento de scroll mostrou a barra saindo da tela). O form é o único wrapper cujo pai é o `CampaignPageShell` (altura da página inteira) — aí o sticky vale o scroll todo. O trailing (picker de colunas) é `hidden md:flex` (B137) — no mobile o trailing é vazio, então nada fora da barra "gruda" por engano (verificado: `CampaignColumnPickerTrailing` é desktop-only). **10 call sites** aplicam a constante no próprio `<form>`; a **agenda é a exceção** — o rebase em `main` (2026-08-10) encontrou a `activity-agenda-filter-strip` entregue pelo **C101** (strip sticky próprio, merge anterior): o wrapper do B184 foi descartado no conflito e a agenda mantém o dono existente (o chassis ainda a cobre no campo/X/Limpar).
|
||||
- X circular de limpar dentro do box (após o input): `onClearAll && (hasChips || query.length > 0)` → `<button type="button" aria-label="Limpar" className="…rounded-full bg-muted… md:hidden" onClick={onClearAll + setActiveChipId(null) + refocus}>`. **`aria-label="Limpar"`** = paridade de nome com o botão desktop que substitui (consistência de voz/leitor de tela entre viewports) e mantém o e2e mobile do `campaignActivity` (que clica `Limpar` em 390px) verde sem edição.
|
||||
- Botão texto "Limpar" (trailing) → `hidden md:inline-flex`.
|
||||
- **Ajustes do `/simplify` (2026-08-09):** o X é renderizado sempre que `onClearAll` existe, alternando `invisible pointer-events-none` quando não há o que limpar (a largura do input não pula na fronteira vazio↔digitado — revisor de performance); hit target `size-11` com círculo visual de 28px interno (convenção `min-h-11` de toque do repo) + `focus-visible:ring-2`; live region própria com `key` (remount re-anuncia "Busca e filtros limpos." — limpar só busca não navega e o pending region ficaria mudo para AT, P1 do revisor de a11y).
|
||||
- **`MunicipalityFilters`** (`src/components/campaign/municipality/MunicipalityFilters.tsx`): só o gating do Save no trailing: `className="hidden md:inline-flex"`. (Nenhuma mudança nos demais 10 `*Filters` — herdam do chassis.)
|
||||
- **`SaveMunicipalityFilterControl`** (`src/components/campaign/municipality/SaveMunicipalityFilterControl.tsx`): props novos `presentation?: 'panel' | 'icon'` (default `panel` = atual) e `className?` no Button trigger. `icon`: `variant="ghost" size="icon"` + `aria-label` sempre (`Renomear o filtro salvo ${name}` / `Salvar filtro`). Popover/conteúdo idêntico.
|
||||
- **`page.tsx`** (`src/app/(campaign)/campanha/(app)/municipios/page.tsx`): `SetCampaignHeaderAction id="municipality-save-filter"` com `SaveMunicipalityFilterControl presentation="icon" state={state} className="md:hidden text-primary-foreground hover:bg-primary-foreground/10 hover:text-primary-foreground"` (padrão visual do sino, `CampaignNotificationBell`).
|
||||
- **`MunicipalityListMobileCards`** (`src/components/campaign/municipality/MunicipalityListMobileCards.tsx`): container `gap-4` → `gap-0 md:gap-4` + `-mx-4 md:mx-0`; `<article>` `rounded-xl border` → `border-b last:border-b-0 md:border md:rounded-xl md:last:border-b`; overlay do Link `after:rounded-none md:after:rounded-xl`. Gate por viewport `md` (não container query) — casado com o omnibox; janela desktop estreita (md+ viewport, cards visíveis < 48rem de container) mantém o look atual nos dois.
|
||||
- **`CampaignUpdatesFilters`** (`src/components/campaign/municipality/CampaignUpdatesFilters.tsx`): o único trailing visível no mobile entre os 11 call sites era o botão "Nova atualização" — com o sticky no form ele viraria parte da barra, contradizendo o padrão "trailing fora da sticky". **`/simplify` decidiu:** trailing vira `hidden md:inline-flex` e o CTA registra um icon button no header mobile via `SetCampaignHeaderAction` (mesmo padrão C94/C95 desta entrega; o estado do modal vive no próprio componente).
|
||||
- **`campaignMobileHeaderIconClassName`** (`src/components/campaign/shell/CampaignMobileTopBar.tsx`): constante compartilhada do look de icon do header (branco sobre primary) — 3º consumidor (sino, wizard, icons de B184), seguindo o precedente de constantes de classe do repo.
|
||||
- **Migration:** nenhuma. **Access/Consent:** nenhum toque.
|
||||
|
||||
### Dados → forma (se aplicável)
|
||||
|
||||
N/A — item de acabamento visual; nenhum dado/KPI muda (confirmado na intenção).
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Chassis + call sites** — `CampaignListOmnibox` (label/box/sticky/X/Limpar), `MunicipalityFilters` (gating Save), `SaveMunicipalityFilterControl`, `page.tsx`, cards. UI pura. Gate `pnpm gate:fast` no meio.
|
||||
2. **E2E** — novo describe 390×844 em `campaignMunicipalities.e2e.spec.ts`: label invisível; box sem borda (`toHaveCSS('border-top-width','0px')`); form sticky (`toHaveCSS('position','sticky')`) + linha separadora (`border-bottom-width 1px`); X com chips e com busca, some sem eles, clicar limpa (URL + busca) **e pina o contrato foco→input + `aria-expanded=false`** (o popover não reabre — regressão real pega no `campaignActivity`); icon "Salvar filtro" no header **salva de verdade e vira "Renomear o filtro salvo X"** (contrato 2.5.3 da apresentação icon); cards `border-top-width 0px`/`border-bottom-width 1px`; **pin desktop invertido** (resize 1280 → label visível, borda 1px, "Limpar" texto de volta). Validar que `campaignSavedFilters` mobile e `campaignActivity:160` (clica "Limpar" em 390px → agora o X) seguem verdes **sem edição**.
|
||||
3. **Gates + push** — `pnpm gate:fast`; `pnpm push`; PR `--base main` com `Closes #514`; auto-merge; `gh pr checks --watch --required`.
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- **Redesenhar o trailing / picker de colunas.** Segue onde está (fora da região sticky) — o padrão não move trailing de nenhuma lista; ações desktop-only seguem `hidden md:flex`.
|
||||
- **Cards/edge-to-edge e salvar no header nas outras listas.** Não existem outras árvores de cards B42 nem saved filters — fora de escopo por superfície, não por escolha.
|
||||
- **Tornar o header sticky.** Não é o pedido — a top bar já fica fixa fora do scrollport.
|
||||
- **`useIsMobile` / JS de viewport.** Branch 100% CSS responsivo (`md:`), como o par `md:hidden`/`hidden md:block` de B42.
|
||||
- **Mudar o comportamento do X além do `onClearAll` existente** — reusa a action que o "Limpar" já dispara em cada lista (todas limpam query + filtros; verificado).
|
||||
- **A11y do combobox:** teclado (arrows/Escape/Backspace), chips e popover de sugestões intactos — o X é botão extra no box.
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **Duas famílias de breakpoint (viewport `md` vs container query 48rem).** Janela desktop estreita mostra cards (container < 48rem) com estilo desktop (md+ viewport) — divergência pré-existente de B42, agora consistente porque omnibox e cards usam o mesmo gate `md`. E2E cobre 390px (celular real, onde os sistemas concordam).
|
||||
- **Sticky não escapa do containing block.** O `sticky` da coluna do input falhava porque o pai (wrapper do omnibox) tem só a altura da própria linha — a barra "grudava" ~12px e rolava junto (reproduzido e medido no browser). Solução: sticky no `<form>` (pai = `CampaignPageShell`, altura total). O shell tem ancestrais com `overflow: hidden`/`contain` (`SidebarInset`, `CampaignAISidebarShell`, RRP) — não afetam porque o form é filho direto do pageShell dentro do scrollport real (`CampaignContentScroll`); validação por scroll no browser em cada página tocada.
|
||||
- **Perda do nome acessível do input no mobile.** Mitigado pelo `aria-label` no input.
|
||||
- **E2E existente:** `campaignSavedFilters` mobile clica `Salvar filtro` em 390px → casa com o icon do header (`md:hidden` no painel desktop e `md:hidden` no header invertido); desktop (`:46`) idem. `campaignActivity:160` clica `Limpar` em 390px → casa com o X (mesmo nome). Validar na fase 2; editar só se algum detalhe escapar.
|
||||
- **`toHaveClass`/`not.toHaveClass` com classes responsivas é inútil** (variantes `md:` ficam no attribute em qualquer viewport) → e2e usa `toHaveCSS`.
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [x] Aceite de produto da intenção ainda coberto (omnibox sem moldura + sticky + linha + X + label oculta no mobile de **todas** as listas; cards edge-to-edge e salvar no header em municípios; desktop inalterado)
|
||||
- [x] Invariantes AGENTS/engineering-standards (sem migration/access/Consent; copy pt-BR; identificadores em inglês; chassis é o dono do padrão — nenhum twin)
|
||||
- [x] Testes de domínio previstos: e2e mobile novo (390×844) em `campaignMunicipalities.e2e.spec.ts`; suites desktop existentes seguem verdes sem edição
|
||||
|
|
@ -10,6 +10,8 @@ Canvas UI: `/home/fsolla/.cursor/projects/home-fsolla-cursor-worktrees-teqo-plan
|
|||
Appetite: ~0,5–1 dia eng; um encaixe em lista existente
|
||||
Responsável: —
|
||||
|
||||
> **Escopo ampliado (decisão do produto, 2026-08-09):** o look sem moldura/sticky do filtro vira o **padrão mobile do chassis `CampaignListOmnibox` — todas as listas `/campanha`** (11 call sites), não só municípios. Cards edge-to-edge e "Salvar filtro" no header continuam municípios-only (as outras listas não têm árvore de cards B42 nem saved filters). Desktop permanece inalterado em todas as listas.
|
||||
|
||||
## Intenção
|
||||
|
||||
No celular, a lista de municípios acumula chrome que rouba espaço da fila: a label "Filtrar
|
||||
|
|
@ -25,7 +27,7 @@ como icon button.
|
|||
- **Persona / contexto:** assessor ou coordenador no celular, varrendo a fila de municípios entre visitas; uma mão, atenção curta.
|
||||
- **Job principal:** filtrar e rolar a fila sem lutar com moldura, mantendo o filtro sempre à mão.
|
||||
- **Fluxo desejado:** abre `/campanha/municipios` no celular → vê o filtro limpo, sem label, preso sob a barra superior → limpa tudo com o X circular dentro do input (só aparece quando há o que limpar) → salva o recorte pelo icon no header → rola a fila de cards contínuos (só uma linha separando cada um) → o filtro permanece visível o tempo todo → toca num card e abre o detalhe.
|
||||
- **Anti-goals de produto:** não virar redesenho geral do shell; não mudar o comportamento de filtragem (chips continuam dentro da omnibox); não alterar desktop; não tocar nas outras listas.
|
||||
- **Anti-goals de produto:** não virar redesenho geral do shell; não mudar o comportamento de filtragem (chips continuam dentro da omnibox); não alterar desktop; cards edge-to-edge e salvar no header não vazam para as outras listas.
|
||||
|
||||
### Esboço de fluxo (B)
|
||||
|
||||
|
|
@ -34,13 +36,13 @@ Ver Canvas UI (rascunho side-by-side "Hoje × Depois"). Jornada textual:
|
|||
|
||||
## Objetivo e aceite
|
||||
|
||||
- No mobile, a label "Filtrar municípios" não é exibida na lista de municípios.
|
||||
- O filtro é visualmente sem moldura (sem caixa/borda), fica **sempre visível** enquanto o usuário rola os cards, e uma linha horizontal o separa do feed abaixo.
|
||||
- Os chips de filtro ativo continuam **dentro da omnibox**, com o mesmo comportamento de hoje.
|
||||
- O "Limpar" (botão de texto atual) sai do mobile; no lugar, um **X circular dentro do input, junto à borda direita**, que limpa os filtros — aparece só quando há o que limpar (filtros ativos ou busca digitada).
|
||||
- O "Salvar filtro" atual sai da região do filtro; no mobile ele vira **icon button no header** (o popover de nomear/renomear continua o mesmo).
|
||||
- Os cards de município no mobile ficam sem borda nem arredondamento, edge-to-edge (sem respiro lateral da página na região da lista), com uma única linha horizontal separando um card do outro.
|
||||
- Desktop da lista e as demais listas `/campanha` ficam inalteradas.
|
||||
- No mobile, a label do filtro (ex.: "Filtrar municípios") não é exibida — **em todas as listas** (padrão do chassis).
|
||||
- O filtro é visualmente sem moldura (sem caixa/borda), fica **sempre visível** enquanto o usuário rola os cards/linhas, e uma linha horizontal o separa do feed abaixo — **em todas as listas** (padrão do chassis).
|
||||
- Os chips de filtro ativo continuam **dentro da omnibox**, com o mesmo comportamento de hoje — todas as listas.
|
||||
- O "Limpar" (botão de texto atual) sai do mobile; no lugar, um **X circular dentro do input, junto à borda direita**, que limpa os filtros — aparece só quando há o que limpar (filtros ativos ou busca digitada) — todas as listas.
|
||||
- O "Salvar filtro" atual sai da região do filtro; no mobile ele vira **icon button no header** (o popover de nomear/renomear continua o mesmo) — **municípios apenas**.
|
||||
- Os cards de município no mobile ficam sem borda nem arredondamento, edge-to-edge (sem respiro lateral da página na região da lista), com uma única linha horizontal separando um card do outro — **municípios apenas** (única lista com cards mobile).
|
||||
- Desktop de todas as listas fica inalterado.
|
||||
|
||||
## Dados (intenção)
|
||||
|
||||
|
|
@ -49,9 +51,9 @@ Ver Canvas UI (rascunho side-by-side "Hoje × Depois"). Jornada textual:
|
|||
|
||||
## Direção no codebase (hipótese)
|
||||
|
||||
- **Áreas prováveis:** `src/components/campaign/municipality/MunicipalityFilters.tsx` (label/placeholder do filtro e onde as ações nascem), `src/components/campaign/municipality/SaveMunicipalityFilterControl.tsx` (popover de salvar — só muda o ancoradouro/trigger no mobile), `src/components/campaign/municipality/MunicipalityListMobileCards.tsx` (cards `rounded-xl border`), `src/app/(campaign)/campanha/(app)/municipios/page.tsx` + `CampaignPageShell` (padding/respiro da região), `src/components/campaign/shell/CampaignMobileTopBar.tsx` (header — destino do icon de salvar).
|
||||
- **Áreas prováveis:** `src/components/campaign/shared/CampaignListOmnibox.tsx` (label/placeholder do filtro e onde as ações nascem — o chassis compartilhado B127 agora é o **dono do padrão**: todos os 11 `*Filters` herdam), `src/components/campaign/municipality/SaveMunicipalityFilterControl.tsx` (popover de salvar — só muda o ancoradouro/trigger no mobile), `src/components/campaign/municipality/MunicipalityListMobileCards.tsx` (cards `rounded-xl border`), `src/app/(campaign)/campanha/(app)/municipios/page.tsx` (registro do icon no header via `SetCampaignHeaderAction`, slot C94/C95).
|
||||
- **Precedente a olhar:** `docs/plans/polimento-mobile-lista-municipios.md` (B42, mobile já entregue), Issues B120 (filtro combobox mobile) e B127 (chassis da omnibox).
|
||||
- **Risco de acoplamento:** o filtro é o chassis compartilhado `CampaignListOmnibox` (B127) usado por várias listas — a intenção é escopada ao **mobile de municípios**; o executor escolhe como escopar (prop/variant ou estilo local), mas o comportamento das outras listas não pode mudar.
|
||||
- **Risco de acoplamento:** o filtro é o chassis compartilhado `CampaignListOmnibox` (B127) usado por várias listas — o padrão mobile é **intencionalmente** no chassis (decisão de produto), e o desktop é preservado por variantes `md:`; cards/salvar no header ficam escopados a municípios.
|
||||
|
||||
## Dependências
|
||||
|
||||
|
|
@ -59,23 +61,23 @@ Ver Canvas UI (rascunho side-by-side "Hoje × Depois"). Jornada textual:
|
|||
|
||||
## Fora de escopo
|
||||
|
||||
- Desktop da lista de municípios (sem mudança).
|
||||
- Demais listas `/campanha` (apoiadores, lideranças, atividades, demandas…).
|
||||
- Desktop de todas as listas (sem mudança).
|
||||
- Cards edge-to-edge e "Salvar filtro" no header fora de municípios (outras listas não têm cards B42 nem saved filters).
|
||||
- Novos filtros, ordenação ou mudança de dados nos cards.
|
||||
- Redesign do shell/top bar.
|
||||
|
||||
## Rabbit holes de produto
|
||||
|
||||
- **"Já que é sem moldura, melhora as outras listas também."** Explosão de superfície e review. **Corte neste item:** só `/campanha/municipios` mobile; as demais ficam para pedido próprio se aprovado.
|
||||
- **"Já que é sem moldura, melhora as outras listas também."** ~~Explosão de superfície e review.~~ **Decidido (2026-08-09):** o look bare/sticky **é** o padrão do chassis para todas as listas no mobile — por isso a mudança mora no chassis e não no estilo local de municípios. O que fica cortado: cards edge-to-edge e salvar no header nas outras listas.
|
||||
- **"Sticky é oportunidade de redesenhar o filtro."** Nova interação sem pedido. **Corte:** mantém chips (dentro da omnibox), sugestões e o fluxo de salvar (só muda o ancoradouro); só muda presença visual, posição fixa e os dois controles de ação.
|
||||
- **"Edge-to-edge é todo o app."** Redesign de shell. **Corte:** só a região da lista mobile.
|
||||
|
||||
## Questões em aberto (produto)
|
||||
|
||||
- **Remover a label só no mobile ou em todas as resoluções?** **Opções:** A) mobile-only (desktop mantém label); B) remover em tudo. **Recomendação:** A — o pedido é mobile e no desktop a label ajuda descoberta sem custo de espaço. _(assumido — validar com produto)_
|
||||
- **Cards edge-to-edge até a borda física da tela, ou só até o padding da página?** **Opções:** A) até a borda da tela (sem padding lateral na região da lista); B) mantém o padding atual, só troca borda por linha. **Recomendação:** A — "seamlessly edge-to-edge" é explícito no pedido. _(assumido — validar)_
|
||||
- **O X circular de limpar aparece quando?** **Opções:** A) só com filtros ativos (chips) ou busca digitada — padrão de clear input; B) sempre visível. **Recomendação:** A — segue a mesma regra do "Limpar" de hoje (que só existe quando há o que limpar) e não polui o estado vazio. _(decidido no gate — chips sempre dentro da omnibox)_
|
||||
- **O icon de salvar filtro no header aparece quando?** **Opções:** A) só quando há recorte ativo para salvar (mesma regra do controle atual, que se omite sem filtros aplicados); B) sempre visível. **Recomendação:** A — o controle atual já se esconde sem recorte; o header ganha o mesmo gate, senão o icon não faria nada. _(assumido — validar)_
|
||||
- **Remover a label só no mobile ou em todas as resoluções?** **Decidido:** A — mobile-only; no desktop a label ajuda descoberta sem custo de espaço. Aplica-se ao padrão do chassis (todas as listas).
|
||||
- **Cards edge-to-edge até a borda física da tela, ou só até o padding da página?** **Decidido:** A — até a borda da tela ("seamlessly edge-to-edge" é explícito no pedido), via sangria do `p-4` do scrollport.
|
||||
- **O X circular de limpar aparece quando?** **Decidido:** A — só com filtros ativos (chips) ou busca digitada — padrão de clear input; segue a mesma regra do "Limpar" de hoje ampliada à busca digitada.
|
||||
- **O icon de salvar filtro no header aparece quando?** **Decidido:** A — só quando há recorte ativo para salvar (o controle já se omite sem recorte; o header ganha o mesmo gate).
|
||||
- **Região sticky do filtro:** chips e busca formam a região que gruda sob a barra (confirmado no gate — chips continuam dentro da omnibox). O picker de colunas segue onde está hoje, fora da região sticky.
|
||||
|
||||
## Referências
|
||||
|
|
|
|||
44
docs/plans/sticky-tabela-desktop-quebrado.md
Normal file
44
docs/plans/sticky-tabela-desktop-quebrado.md
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
# D7 — Sticky dos headers da tabela desktop quebrado (headers não grudam ao rolar)
|
||||
|
||||
Status: registrado
|
||||
Atualizado em: 2026-08-10
|
||||
Issue: (criada via agent:register)
|
||||
Priority: P3
|
||||
Model: composer-2.5
|
||||
Impeccable: A (só backend/UI interna)
|
||||
|
||||
## Diagnóstico (medido durante B184, 2026-08-09)
|
||||
|
||||
Na lista de municípios em desktop (viewport 1440px, prod build local), os headers da tabela
|
||||
(`[&_th]:sticky [&_th]:top-0` em `MunicipalityList.tsx` → `CampaignTable`) **não grudam**: o `th`
|
||||
computa `position: sticky`, mas rola junto com o conteúdo (medido: `top` 174 → −326 ao scrollar
|
||||
500px no scrollport). A feature do B41 está silenciosamente morta.
|
||||
|
||||
**Causa-raiz (mesma classe de bug do sticky do omnibox B184):** o wrapper da tabela
|
||||
(`CampaignTable` `containerClassName="overflow-x-auto …"`) é um **scroll container** (overflow-x
|
||||
não-`visible` torna o elemento scroll container nos dois eixos). Ele nunca rola verticalmente —
|
||||
o scroll vertical acontece no `#campaign-content-scroll` (pai). O `position: sticky` do `th` gruda
|
||||
relativo ao **scroll container mais próximo** (o wrapper), que não rola → o sticky nunca engaja.
|
||||
`[&_th]:sticky` é hoje CSS morto em todas as listas que usam `CampaignTable`.
|
||||
|
||||
## Abordagem sugerida (investigar na execução)
|
||||
|
||||
- **A)** Fazer o wrapper **não** ser scroll container para o eixo vertical mantendo o horizontal:
|
||||
`overflow-x: clip` não rola — precisa de outro mecanismo para tabelas mais largas que o container.
|
||||
- **B)** Estrutural: mover o scroll horizontal para dentro do fluxo vertical (ex. o scroll acontece
|
||||
na região da tabela com o header fixo por JS/`position: sticky` num elemento irmão do scroller).
|
||||
- **C)** Aceitar e remover as classes `[&_th]:sticky` mortas (documentar o não-suporte).
|
||||
- **Recomendação preliminar:** reproduzir nas N listas `CampaignTable`; avaliar B com um sticky
|
||||
real só no header (JS-free se houver arranjo de DOM possível, senão pin por scroll listener no
|
||||
scrollport); C como fallback honesto. Fora de escopo: mudar o padrão de scroll da página.
|
||||
|
||||
## Escopo
|
||||
|
||||
- Reprodução: `/campanha/municipios` (e demais listas com `CampaignTable`).
|
||||
- Sem migration, access, dados.
|
||||
|
||||
## Rabbit holes
|
||||
|
||||
- Reescrever o layout das listas para "inner scroll" sem pedido — se a solução exigir mudança de
|
||||
UX de scroll, parar e propor antes.
|
||||
- Mover o scroll para o `tbody`/tabela via `display: block` — quebra a semântica/larguras de coluna.
|
||||
|
|
@ -14,10 +14,14 @@ import {
|
|||
municipalityListPickerColumns,
|
||||
} from '@/components/campaign/municipality/MunicipalityList'
|
||||
import { MunicipalityListPageChrome } from '@/components/campaign/municipality/MunicipalityListPageChrome'
|
||||
import { SaveMunicipalityFilterControl } from '@/components/campaign/municipality/SaveMunicipalityFilterControl'
|
||||
import { CampaignColumnPickerTrailing } from '@/components/campaign/shared/CampaignColumnPickerTrailing'
|
||||
import { CampaignListFooter } from '@/components/campaign/shared/CampaignListFooter'
|
||||
import { campaignMobileHeaderIconClassName } from '@/components/campaign/shell/CampaignMobileTopBar'
|
||||
import { SetCampaignHeaderAction } from '@/components/campaign/shell/CampaignPageChromeContext'
|
||||
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
|
||||
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
isCampaignCoordinator,
|
||||
isCampaignStaff,
|
||||
|
|
@ -215,6 +219,18 @@ export default async function MunicipalitiesPage({ searchParams }: Municipalitie
|
|||
return (
|
||||
<CampaignPageShell>
|
||||
<MunicipalityListPageChrome />
|
||||
{/*
|
||||
B184 — on mobile the save-recorte control anchors in the app top bar
|
||||
(icon button, md:hidden), reusing the C94/C95 header-action slot; the
|
||||
naming popover is the same component the filter bar renders on desktop.
|
||||
*/}
|
||||
<SetCampaignHeaderAction id="municipality-save-filter">
|
||||
<SaveMunicipalityFilterControl
|
||||
presentation="icon"
|
||||
state={state}
|
||||
className={cn('md:hidden', campaignMobileHeaderIconClassName)}
|
||||
/>
|
||||
</SetCampaignHeaderAction>
|
||||
{main}
|
||||
{listVisitLabel ? (
|
||||
<RecentVisitTracker
|
||||
|
|
|
|||
|
|
@ -2,7 +2,10 @@
|
|||
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
import { CampaignListOmnibox } from '@/components/campaign/shared/CampaignListOmnibox'
|
||||
import {
|
||||
CampaignListOmnibox,
|
||||
campaignListOmniboxFormClassName,
|
||||
} from '@/components/campaign/shared/CampaignListOmnibox'
|
||||
import type { RelationOption } from '@/components/campaign/shared/RelationMultiSelect'
|
||||
import { useCampaignListFilterNavigation } from '@/components/campaign/shared/useCampaignListFilterNavigation'
|
||||
import {
|
||||
|
|
@ -77,6 +80,7 @@ export const ActivityFilters = ({
|
|||
return (
|
||||
<form
|
||||
role="search"
|
||||
className={campaignListOmniboxFormClassName}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,10 @@
|
|||
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
import { CampaignListOmnibox } from '@/components/campaign/shared/CampaignListOmnibox'
|
||||
import {
|
||||
CampaignListOmnibox,
|
||||
campaignListOmniboxFormClassName,
|
||||
} from '@/components/campaign/shared/CampaignListOmnibox'
|
||||
import { useCampaignListFilterNavigation } from '@/components/campaign/shared/useCampaignListFilterNavigation'
|
||||
import type { AdvisorFilterOption } from '@/utilities/advisor/advisorListFilters'
|
||||
import { advisorListHrefForPage, type AdvisorListState } from '@/utilities/advisor/advisorListUrl'
|
||||
|
|
@ -65,6 +68,7 @@ export const AdvisorFilters = ({
|
|||
return (
|
||||
<form
|
||||
role="search"
|
||||
className={campaignListOmniboxFormClassName}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,10 @@
|
|||
|
||||
import { useMemo, useState, type ReactNode } from 'react'
|
||||
|
||||
import { CampaignListOmnibox } from '@/components/campaign/shared/CampaignListOmnibox'
|
||||
import {
|
||||
CampaignListOmnibox,
|
||||
campaignListOmniboxFormClassName,
|
||||
} from '@/components/campaign/shared/CampaignListOmnibox'
|
||||
import { useCampaignListFilterNavigation } from '@/components/campaign/shared/useCampaignListFilterNavigation'
|
||||
import { buildDemandListHref, type DemandListState } from '@/utilities/demand/demandListUrl'
|
||||
import {
|
||||
|
|
@ -49,6 +52,7 @@ export const DemandFilters = ({
|
|||
return (
|
||||
<form
|
||||
role="search"
|
||||
className={campaignListOmniboxFormClassName}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,10 @@
|
|||
|
||||
import { useMemo, useState, type ReactNode } from 'react'
|
||||
|
||||
import { CampaignListOmnibox } from '@/components/campaign/shared/CampaignListOmnibox'
|
||||
import {
|
||||
CampaignListOmnibox,
|
||||
campaignListOmniboxFormClassName,
|
||||
} from '@/components/campaign/shared/CampaignListOmnibox'
|
||||
import { useCampaignListFilterNavigation } from '@/components/campaign/shared/useCampaignListFilterNavigation'
|
||||
import {
|
||||
buildLeadershipFilterHref,
|
||||
|
|
@ -109,6 +112,7 @@ export const LeadershipFilters = ({
|
|||
return (
|
||||
<form
|
||||
role="search"
|
||||
className={campaignListOmniboxFormClassName}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -4,10 +4,16 @@ import { Plus } from 'lucide-react'
|
|||
import { useMemo, useState } from 'react'
|
||||
|
||||
import { CampaignUpdatesCreateModal } from '@/components/campaign/municipality/CampaignUpdatesCreateModal'
|
||||
import { CampaignListOmnibox } from '@/components/campaign/shared/CampaignListOmnibox'
|
||||
import {
|
||||
CampaignListOmnibox,
|
||||
campaignListOmniboxFormClassName,
|
||||
} from '@/components/campaign/shared/CampaignListOmnibox'
|
||||
import { useCampaignListFilterNavigation } from '@/components/campaign/shared/useCampaignListFilterNavigation'
|
||||
import { campaignMobileHeaderIconClassName } from '@/components/campaign/shell/CampaignMobileTopBar'
|
||||
import { SetCampaignHeaderAction } from '@/components/campaign/shell/CampaignPageChromeContext'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { getMunicipalityCatalogEntry } from '@/lib/municipalityCatalog'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { CampaignUpdatesFeedFacets } from '@/utilities/municipality/campaignUpdatesFeedData'
|
||||
import {
|
||||
buildCampaignUpdatesFeedHref,
|
||||
|
|
@ -97,6 +103,7 @@ export const CampaignUpdatesFilters = ({ state, facets, isStaff }: CampaignUpdat
|
|||
<>
|
||||
<form
|
||||
role="search"
|
||||
className={campaignListOmniboxFormClassName}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
}}
|
||||
|
|
@ -123,10 +130,13 @@ export const CampaignUpdatesFilters = ({ state, facets, isStaff }: CampaignUpdat
|
|||
runAction(clearCampaignUpdatesFeedFilters())
|
||||
}}
|
||||
trailing={
|
||||
// B184 — on mobile the create action anchors in the app top bar
|
||||
// (icon button) so the sticky filter bar stays a field-only strip;
|
||||
// the desktop copy keeps the labeled button beside the omnibox.
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="min-h-11 shrink-0 gap-2"
|
||||
className="min-h-11 hidden shrink-0 gap-2 md:inline-flex"
|
||||
onClick={() => setCreateOpen(true)}
|
||||
>
|
||||
<Plus aria-hidden="true" />
|
||||
|
|
@ -135,6 +145,18 @@ export const CampaignUpdatesFilters = ({ state, facets, isStaff }: CampaignUpdat
|
|||
}
|
||||
/>
|
||||
</form>
|
||||
<SetCampaignHeaderAction id="campaign-updates-new">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn('size-11 md:hidden', campaignMobileHeaderIconClassName)}
|
||||
aria-label="Nova atualização"
|
||||
onClick={() => setCreateOpen(true)}
|
||||
>
|
||||
<Plus className="size-5" aria-hidden />
|
||||
</Button>
|
||||
</SetCampaignHeaderAction>
|
||||
<CampaignUpdatesCreateModal
|
||||
open={createOpen}
|
||||
onOpenChange={setCreateOpen}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,10 @@ import { Fragment, useMemo, useState, type ReactNode } from 'react'
|
|||
|
||||
import { useMunicipalityEstimateScenarioOptional } from '@/components/campaign/municipality/MunicipalityEstimateScenarioContext'
|
||||
import { SaveMunicipalityFilterControl } from '@/components/campaign/municipality/SaveMunicipalityFilterControl'
|
||||
import { CampaignListOmnibox } from '@/components/campaign/shared/CampaignListOmnibox'
|
||||
import {
|
||||
CampaignListOmnibox,
|
||||
campaignListOmniboxFormClassName,
|
||||
} from '@/components/campaign/shared/CampaignListOmnibox'
|
||||
import { useCampaignListFilterNavigation } from '@/components/campaign/shared/useCampaignListFilterNavigation'
|
||||
import { DEFAULT_VOTE_ESTIMATE_SCENARIO } from '@/lib/voteEstimate'
|
||||
import {
|
||||
|
|
@ -156,6 +159,7 @@ export const MunicipalityFilters = ({
|
|||
return (
|
||||
<form
|
||||
role="search"
|
||||
className={campaignListOmniboxFormClassName}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
}}
|
||||
|
|
@ -187,7 +191,13 @@ export const MunicipalityFilters = ({
|
|||
// municipios render (and fails the e2e console guard). Keyed array
|
||||
// children keep the two controls side by side without the warning.
|
||||
<Fragment key="column-picker">{trailing}</Fragment>,
|
||||
<SaveMunicipalityFilterControl key="save-filter" state={state} />,
|
||||
// B184 — on mobile the save control lives in the app header (icon
|
||||
// button), so the filter-bar copy is desktop-only.
|
||||
<SaveMunicipalityFilterControl
|
||||
key="save-filter"
|
||||
state={state}
|
||||
className="hidden md:inline-flex"
|
||||
/>,
|
||||
]}
|
||||
/>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -87,7 +87,12 @@ export const MunicipalityListMobileCards = ({
|
|||
}: MunicipalityListMobileCardsProps) => (
|
||||
<div
|
||||
data-view="mobile-cards"
|
||||
className="flex flex-col gap-4 @min-[48rem]/municipality-list:hidden"
|
||||
// B184 — borderless, edge-to-edge cards below `md`: no page breathing room
|
||||
// (`-mx-4` bleeds past the scrollport's mobile `p-4`), one horizontal line
|
||||
// between cards (`border-b`), nothing between the last card and the footer.
|
||||
// The `md:` variants restore the framed desktop look for the narrow-desktop
|
||||
// window that still sees this tree (container < 48rem).
|
||||
className="flex flex-col gap-0 -mx-4 md:mx-0 md:gap-4 @min-[48rem]/municipality-list:hidden"
|
||||
>
|
||||
{municipalities.length === 0 ? emptySlot : null}
|
||||
{municipalities.map((municipality) => {
|
||||
|
|
@ -101,14 +106,14 @@ export const MunicipalityListMobileCards = ({
|
|||
return (
|
||||
<article
|
||||
key={municipality.id}
|
||||
className="relative flex flex-col gap-3 rounded-xl border p-4"
|
||||
className="relative flex flex-col gap-3 rounded-none border-b p-4 last:border-b-0 md:rounded-xl md:border md:last:border-b"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="flex items-center gap-1.5 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"
|
||||
className="rounded-md after:absolute after:inset-0 after:rounded-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring md:after:rounded-xl"
|
||||
>
|
||||
{municipality.name}
|
||||
</Link>
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { Button } from '@/components/ui/button'
|
|||
import { Field, FieldDescription, FieldLabel } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
buildMunicipalitySavedFilterHref,
|
||||
formatMunicipalityActiveFiltersSummary,
|
||||
|
|
@ -24,6 +25,14 @@ import {
|
|||
type SaveMunicipalityFilterControlProps = {
|
||||
/** The APPLIED state — a search still inside the debounce window is not in the URL yet. */
|
||||
state: MunicipalityListState
|
||||
/**
|
||||
* B184 — trigger shape: `panel` is the filter-bar button with text label
|
||||
* (desktop); `icon` is the icon-only header button (mobile top bar). The
|
||||
* naming popover is identical in both.
|
||||
*/
|
||||
presentation?: 'panel' | 'icon'
|
||||
/** Extra classes for the trigger button (e.g. viewport gating, header colors). */
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -34,7 +43,11 @@ type SaveMunicipalityFilterControlProps = {
|
|||
* that need a note — and it keeps this control out of the auto-save machine the
|
||||
* quick-edit cells share.
|
||||
*/
|
||||
export const SaveMunicipalityFilterControl = ({ state }: SaveMunicipalityFilterControlProps) => {
|
||||
export const SaveMunicipalityFilterControl = ({
|
||||
state,
|
||||
presentation = 'panel',
|
||||
className,
|
||||
}: SaveMunicipalityFilterControlProps) => {
|
||||
const savedFilters = useMunicipalitySavedFilters()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [name, setName] = useState('')
|
||||
|
|
@ -81,20 +94,31 @@ export const SaveMunicipalityFilterControl = ({ state }: SaveMunicipalityFilterC
|
|||
toast.success(existing ? 'Nome atualizado.' : 'Filtro salvo em Municípios, na navegação.')
|
||||
}
|
||||
|
||||
const renameLabel = existing ? `Renomear o filtro salvo ${existing.name}` : undefined
|
||||
const iconLabel = presentation === 'icon' ? (renameLabel ?? 'Salvar filtro') : undefined
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="min-h-11 shrink-0 md:self-end"
|
||||
size={presentation === 'icon' ? 'icon' : undefined}
|
||||
className={cn(
|
||||
'shrink-0',
|
||||
presentation === 'panel' && 'min-h-11 md:self-end',
|
||||
presentation === 'icon' && 'size-11',
|
||||
className,
|
||||
)}
|
||||
// SC 2.5.3: an accessible name has to CONTAIN the visible label, or
|
||||
// "clique em Salvar filtro" matches nothing by voice. The rename case
|
||||
// may add the target's name because it keeps the visible word first.
|
||||
aria-label={existing ? `Renomear o filtro salvo ${existing.name}` : undefined}
|
||||
// The icon presentation has no visible text, so the name is always
|
||||
// the aria-label.
|
||||
aria-label={presentation === 'icon' ? iconLabel : renameLabel}
|
||||
>
|
||||
<BookmarkPlusIcon aria-hidden="true" />
|
||||
{existing ? 'Renomear' : 'Salvar filtro'}
|
||||
{presentation === 'panel' ? (existing ? 'Renomear' : 'Salvar filtro') : null}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
{/* Radix gives the content `role="dialog"` and no name of its own. */}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,10 @@
|
|||
|
||||
import { useMemo, useState, type ReactNode } from 'react'
|
||||
|
||||
import { CampaignListOmnibox } from '@/components/campaign/shared/CampaignListOmnibox'
|
||||
import {
|
||||
CampaignListOmnibox,
|
||||
campaignListOmniboxFormClassName,
|
||||
} from '@/components/campaign/shared/CampaignListOmnibox'
|
||||
import { useCampaignListFilterNavigation } from '@/components/campaign/shared/useCampaignListFilterNavigation'
|
||||
import type { TerritoryFilterOption } from '@/utilities/territory/territoryListFilters'
|
||||
import {
|
||||
|
|
@ -58,6 +61,7 @@ export const TerritoryFilters = ({
|
|||
return (
|
||||
<form
|
||||
role="search"
|
||||
className={campaignListOmniboxFormClassName}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,10 @@
|
|||
|
||||
import { useMemo, useState, type ReactNode } from 'react'
|
||||
|
||||
import { CampaignListOmnibox } from '@/components/campaign/shared/CampaignListOmnibox'
|
||||
import {
|
||||
CampaignListOmnibox,
|
||||
campaignListOmniboxFormClassName,
|
||||
} from '@/components/campaign/shared/CampaignListOmnibox'
|
||||
import { useCampaignListFilterNavigation } from '@/components/campaign/shared/useCampaignListFilterNavigation'
|
||||
import {
|
||||
buildOrganizationListHref,
|
||||
|
|
@ -52,6 +55,7 @@ export const OrganizationFilters = ({
|
|||
return (
|
||||
<form
|
||||
role="search"
|
||||
className={campaignListOmniboxFormClassName}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -29,11 +29,35 @@ export type CampaignListOmniboxProps = {
|
|||
trailing?: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* B184 — the mobile sticky bar. Applied by every list to its own `<form
|
||||
* role="search">`: below `md` the filter region is a borderless bar glued to
|
||||
* the app top bar (the scrollport is `CampaignContentScroll`, whose sibling
|
||||
* the top bar is), with a separating line and an edge-to-edge bleed past the
|
||||
* scrollport's mobile `p-4`. `md:` restores the framed static desktop look.
|
||||
*
|
||||
* Sticky lives on the FORM on purpose: a sticky element can never escape its
|
||||
* containing block (its parent), and the form is the only wrapper whose parent
|
||||
* is the full-height `CampaignPageShell` — a sticky input column inside the
|
||||
* short omnibox row would only "stick" for its own height and then scroll away.
|
||||
*/
|
||||
export const campaignListOmniboxFormClassName =
|
||||
'sticky top-0 z-20 -mx-4 border-b border-border bg-background px-4 py-2 ' +
|
||||
'md:static md:z-auto md:mx-0 md:border-b-0 md:bg-transparent md:px-0 md:py-0'
|
||||
|
||||
/**
|
||||
* Shared list omnibox chassis (B127): chips inside the field, caret to the
|
||||
* right, grouped suggestions from the domain adapter. Navigation / URL policy
|
||||
* stay in the caller.
|
||||
*
|
||||
* Mobile standard (B184, 2026-08-09): below `md` the field is borderless,
|
||||
* sticky under the app top bar with a separating line, the label hides (the
|
||||
* input carries it as `aria-label`), the text "Limpar" is replaced by a
|
||||
* circular X inside the field, and the trailing cluster — empty below `md`
|
||||
* (every list gates its mobile-visible trailing elsewhere, e.g. header
|
||||
* actions) — stays out of the sticky region. Desktop keeps the framed look
|
||||
* unchanged via `md:` variants.
|
||||
*
|
||||
* Keyboard contract mirrors RelationChipCell (ARIA combobox by hand): arrows
|
||||
* move the active option, Enter picks it, Escape closes. cmdk is not used —
|
||||
* its Root expects an inner CommandInput and would leave this field mouse-only.
|
||||
|
|
@ -61,6 +85,8 @@ export const CampaignListOmnibox = ({
|
|||
const [open, setOpen] = useState(false)
|
||||
const [activeChipId, setActiveChipId] = useState<string | null>(null)
|
||||
const [activeIndex, setActiveIndex] = useState(0)
|
||||
/** B184 — increments on every clear so the live region re-announces. */
|
||||
const [clearAnnounceCount, setClearAnnounceCount] = useState(0)
|
||||
|
||||
const hasChips = chips.length > 0
|
||||
const showSuggestions = open && suggestions.length > 0
|
||||
|
|
@ -158,8 +184,12 @@ export const CampaignListOmnibox = ({
|
|||
data-pending={isPending}
|
||||
aria-busy={isPending}
|
||||
>
|
||||
{/* The sticky bar lives on the caller's `<form>` (campaignListOmniboxFormClassName). */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<label htmlFor={id} className="mb-1.5 block text-sm font-medium text-foreground">
|
||||
<label
|
||||
htmlFor={id}
|
||||
className="mb-1.5 block text-sm font-medium text-foreground hidden md:block"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
<Popover
|
||||
|
|
@ -171,7 +201,10 @@ export const CampaignListOmnibox = ({
|
|||
<PopoverAnchor asChild>
|
||||
<div
|
||||
className={cn(
|
||||
'flex min-h-11 w-full flex-wrap items-center gap-1.5 rounded-lg border border-input bg-transparent px-2 py-1.5 shadow-xs',
|
||||
// B184: below `md` the field is a borderless sticky bar; the
|
||||
// `md:` variants restore the framed desktop look.
|
||||
'flex min-h-11 w-full flex-wrap items-center gap-1.5 rounded-lg border-0 bg-transparent px-2 py-1.5 shadow-none',
|
||||
'md:border md:border-input md:shadow-xs',
|
||||
'focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50',
|
||||
)}
|
||||
onMouseDown={(event) => {
|
||||
|
|
@ -206,6 +239,10 @@ export const CampaignListOmnibox = ({
|
|||
ref={inputRef}
|
||||
id={id}
|
||||
role="combobox"
|
||||
// B184: below `md` the visual label is hidden; the accessible
|
||||
// name must not ride on it, so the input carries it directly
|
||||
// (identical to the label's own text on every viewport).
|
||||
aria-label={label}
|
||||
aria-expanded={showSuggestions}
|
||||
aria-controls={showSuggestions ? listboxId : undefined}
|
||||
aria-activedescendant={activeOptionId}
|
||||
|
|
@ -224,6 +261,40 @@ export const CampaignListOmnibox = ({
|
|||
onKeyDown={onKeyDown}
|
||||
className="min-h-8 min-w-[8rem] flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
{onClearAll ? (
|
||||
// B184: the mobile replacement for the text "Limpar" — a
|
||||
// circular X at the field's right edge. Rendered whenever the
|
||||
// caller clears, so the field's width never jumps at the
|
||||
// empty↔typed boundary; `invisible` keeps it out of the a11y
|
||||
// tree and the pointer until there is something to clear
|
||||
// (chips OR a typed search).
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Limpar"
|
||||
className={cn(
|
||||
'inline-flex size-11 shrink-0 items-center justify-center rounded-full',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
'md:hidden',
|
||||
!(hasChips || query.length > 0) && 'invisible pointer-events-none',
|
||||
)}
|
||||
onClick={() => {
|
||||
onClearAll()
|
||||
setActiveChipId(null)
|
||||
setClearAnnounceCount((count) => count + 1)
|
||||
// Refocus the field so the next keystroke keeps typing —
|
||||
// then close the popover: the focus event itself reopens
|
||||
// it, and a stale suggestion list over the results is
|
||||
// worse than an empty field (measured intercepting clicks
|
||||
// in the agenda e2e).
|
||||
inputRef.current?.focus()
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
<span className="flex size-7 items-center justify-center rounded-full bg-muted text-muted-foreground transition-colors hover:bg-muted-foreground/20 hover:text-foreground">
|
||||
<XIcon className="size-4" aria-hidden />
|
||||
</span>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</PopoverAnchor>
|
||||
<PopoverContent
|
||||
|
|
@ -276,12 +347,25 @@ export const CampaignListOmnibox = ({
|
|||
Atualizando resultados…
|
||||
</p>
|
||||
) : null}
|
||||
{clearAnnounceCount > 0 ? (
|
||||
// B184 — clearing only a typed search never navigates (the URL is
|
||||
// already bare), so the pending region above stays silent; the keyed
|
||||
// remount re-announces the clear for screen readers.
|
||||
<p key={clearAnnounceCount} className="sr-only" aria-live="polite">
|
||||
Busca e filtros limpos.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-2 md:pt-7">
|
||||
{trailing}
|
||||
{onClearAll && hasChips ? (
|
||||
<Button type="button" variant="ghost" className="min-h-11 shrink-0" onClick={onClearAll}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="min-h-11 hidden shrink-0 md:inline-flex"
|
||||
onClick={onClearAll}
|
||||
>
|
||||
Limpar
|
||||
</Button>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,15 @@ const wizardDismissButtonClass =
|
|||
|
||||
const wizardTitleSkeletonClass = 'mx-auto motion-reduce:animate-none bg-primary-foreground/20'
|
||||
|
||||
/**
|
||||
* Shared look for icon controls registered into the app top bar's right
|
||||
* cluster (C94/C95 slot) — the same white-on-primary treatment the wizard
|
||||
* buttons and the notification bell use. Consumers compose `md:` overrides on
|
||||
* top (e.g. the bell switches to muted on desktop).
|
||||
*/
|
||||
export const campaignMobileHeaderIconClassName =
|
||||
'text-primary-foreground hover:bg-primary-foreground/10 hover:text-primary-foreground'
|
||||
|
||||
export const CampaignMobileTopBar = ({ notificationBell }: { notificationBell?: ReactNode }) => {
|
||||
const chrome = useCampaignWizardChrome()
|
||||
const homeSearchChrome = useCampaignHomeSearchChrome()
|
||||
|
|
|
|||
|
|
@ -2,7 +2,10 @@
|
|||
|
||||
import { useMemo, useState, type ReactNode } from 'react'
|
||||
|
||||
import { CampaignListOmnibox } from '@/components/campaign/shared/CampaignListOmnibox'
|
||||
import {
|
||||
CampaignListOmnibox,
|
||||
campaignListOmniboxFormClassName,
|
||||
} from '@/components/campaign/shared/CampaignListOmnibox'
|
||||
import { useCampaignListFilterNavigation } from '@/components/campaign/shared/useCampaignListFilterNavigation'
|
||||
import {
|
||||
buildStateDeputyFilterHref,
|
||||
|
|
@ -60,6 +63,7 @@ export const StateDeputyFilters = ({
|
|||
return (
|
||||
<form
|
||||
role="search"
|
||||
className={campaignListOmniboxFormClassName}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,10 @@
|
|||
|
||||
import { useMemo, useState, type ReactNode } from 'react'
|
||||
|
||||
import { CampaignListOmnibox } from '@/components/campaign/shared/CampaignListOmnibox'
|
||||
import {
|
||||
CampaignListOmnibox,
|
||||
campaignListOmniboxFormClassName,
|
||||
} from '@/components/campaign/shared/CampaignListOmnibox'
|
||||
import type { RelationOption } from '@/components/campaign/shared/RelationMultiSelect'
|
||||
import { useCampaignListFilterNavigation } from '@/components/campaign/shared/useCampaignListFilterNavigation'
|
||||
import {
|
||||
|
|
@ -86,6 +89,7 @@ export const SupporterFilters = ({
|
|||
return (
|
||||
<form
|
||||
role="search"
|
||||
className={campaignListOmniboxFormClassName}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -800,6 +800,93 @@ test.describe('Municípios — cards no celular (B42)', () => {
|
|||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* B184 (2026-08-09): on the phone the list-filter chrome is borderless and
|
||||
* sticky under the top bar (the shared omnibox standard), the text "Limpar" is
|
||||
* replaced by a circular X inside the field (chips OR a typed search), the
|
||||
* save-recorte control anchors in the app top bar, and the municipality cards
|
||||
* are edge-to-edge with a single separator line between them.
|
||||
*/
|
||||
test.describe('Municípios — filtro e cards sem moldura no celular (B184)', () => {
|
||||
test.use({ viewport: { width: 390, height: 844 } })
|
||||
|
||||
test('omnibox borderless/sticky, X clears chips and search, save in the top bar, cards edge-to-edge', async ({
|
||||
campaign,
|
||||
page,
|
||||
}) => {
|
||||
const { fixtures } = campaign
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator', {
|
||||
name: fixtures.value('Coordenadora B184'),
|
||||
})
|
||||
const password = coordinator.password
|
||||
|
||||
await campaign.login(page, coordinator.email!, password)
|
||||
await page.goto(`${campaign.baseURL}/campanha/municipios`)
|
||||
|
||||
// The mobile standard hides the field label; the input keeps its name via
|
||||
// aria-label, and the filter form is a borderless sticky bar.
|
||||
await expect(page.getByText('Filtrar municípios', { exact: true })).toBeHidden()
|
||||
const omniboxInput = page.getByRole('combobox', { name: 'Filtrar municípios' })
|
||||
const field = omniboxInput.locator('..')
|
||||
const filterForm = omniboxInput.locator('xpath=ancestor::form[1]')
|
||||
await expect(field).toHaveCSS('border-top-width', '0px')
|
||||
await expect(filterForm).toHaveCSS('position', 'sticky')
|
||||
await expect(filterForm).toHaveCSS('border-bottom-width', '1px')
|
||||
|
||||
// No filters, no search → no clear affordance at all.
|
||||
await expect(page.getByRole('button', { name: 'Limpar', exact: true })).toHaveCount(0)
|
||||
|
||||
// A typed search alone shows the X; clicking it clears the draft, keeps
|
||||
// the focus in the field and does not reopen the suggestion popover (the
|
||||
// contract that broke the agenda e2e before the `setOpen(false)`).
|
||||
await omniboxInput.fill('Feira de Santana')
|
||||
const clearX = page.getByRole('button', { name: 'Limpar', exact: true })
|
||||
await expect(clearX).toBeVisible()
|
||||
await clearX.click()
|
||||
await expect(omniboxInput).toHaveValue('')
|
||||
await expect(omniboxInput).toBeFocused()
|
||||
await expect(omniboxInput).toHaveAttribute('aria-expanded', 'false')
|
||||
await expect(clearX).toHaveCount(0)
|
||||
|
||||
// Active chips also show the X, and clicking it drops the whole recorte.
|
||||
await page.goto(`${campaign.baseURL}/campanha/municipios?region=Recôncavo`)
|
||||
await expect(page.getByRole('button', { name: 'Remover Território: Recôncavo' })).toBeVisible()
|
||||
await expect(clearX).toBeVisible()
|
||||
await clearX.click()
|
||||
await expect(page).toHaveURL(`${campaign.baseURL}/campanha/municipios`)
|
||||
|
||||
// The save-recorte control anchors in the app top bar on mobile — absent
|
||||
// without a recorte; with one, the same naming popover saves and flips the
|
||||
// icon's accessible name to the rename contract (SC 2.5.3).
|
||||
const topBar = page.locator('[data-slot="campaign-mobile-top-bar"]')
|
||||
const headerSave = topBar.getByRole('button', { name: 'Salvar filtro', exact: true })
|
||||
await expect(headerSave).toHaveCount(0)
|
||||
await page.goto(`${campaign.baseURL}/campanha/municipios?region=Recôncavo`)
|
||||
await expect(headerSave).toBeVisible()
|
||||
await headerSave.click()
|
||||
await page.getByLabel('Nome do filtro').fill('Recorte B184')
|
||||
await page.getByRole('button', { name: 'Salvar', exact: true }).click()
|
||||
await expect(
|
||||
topBar.getByRole('button', { name: 'Renomear o filtro salvo Recorte B184' }),
|
||||
).toBeVisible()
|
||||
|
||||
// Cards are edge-to-edge with one horizontal line between them and none
|
||||
// after the last one.
|
||||
const cards = page.locator('[data-view="mobile-cards"] article')
|
||||
await expect(cards.first()).toHaveCSS('border-top-width', '0px')
|
||||
await expect(cards.first()).toHaveCSS('border-left-width', '0px')
|
||||
await expect(cards.first()).toHaveCSS('border-bottom-width', '1px')
|
||||
await expect(cards.last()).toHaveCSS('border-bottom-width', '0px')
|
||||
|
||||
// The desktop frame is untouched: back to a wide viewport the field has
|
||||
// its border again, the label is visible and the text "Limpar" returns.
|
||||
await page.setViewportSize({ width: 1280, height: 900 })
|
||||
await expect(page.getByText('Filtrar municípios', { exact: true })).toBeVisible()
|
||||
await expect(field).toHaveCSS('border-top-width', '1px')
|
||||
await expect(page.getByRole('button', { name: 'Limpar', exact: true })).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* B126: FAB quick-actions overlay — no persistent drawer; opens on demand.
|
||||
*/
|
||||
|
|
|
|||
Loading…
Reference in a new issue