Merge pull request #510 from fsolla/C102-mobile-sem-botao-sheet-de-sidebar-navegacao-pela-barra-

C102 — Mobile: sem botão/sheet de sidebar — navegação pela barra inferior + Mais
This commit is contained in:
Francisco Solla 2026-08-09 19:56:07 -03:00 committed by GitHub
commit 457aad5a21
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 343 additions and 39 deletions

View file

@ -6,6 +6,8 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
**Recently resolved (2026-08-09):** **B183** (Issue #501) — o auto-zoom do Safari iOS em inputs do PWA `/campanha` (font < 16px ao focar, escala que não volta no standalone) foi eliminado pela **causa**: uma regra CSS única em `styles.css` — `@media (pointer: coarse) and (hover: none)` + `[data-theme='campaign'] :is(input, select, textarea, [contenteditable='true']) { font-size: 1rem }` — cobrindo omnibox B127, Command search, native-select e textareas em qualquer orientação do iPhone (landscape chega a 932px de largura, o que descartou escopo por `max-width`). Unlayered vence as utilities `text-sm` sem `!important`; desktop (pointer fine) fica intocado; `Input`/`Textarea` do ui já eram `text-base md:text-sm`. Sem migration, access, Consent ou mudança de componente. E2E novo `tests/e2e/campaignIosInputZoom.e2e.spec.ts` pina o contrato: emulação `devices['iPhone 13']` → omnibox de `/campanha/municipios` computa **16px**; control desktop → permanece **14px**. Intenção [`pwa-ios-autozoom-input-foco.md`](plans/pwa-ios-autozoom-input-foco.md); impl [`pwa-ios-autozoom-input-foco-impl.md`](plans/pwa-ios-autozoom-input-foco-impl.md).
**Recently resolved (2026-08-09):** **C102** (Issue #498) — no mobile (<768px), nenhuma tela `/campanha` de staff mostra mais hamburger nem sheet de sidebar: a navegação staff mora na barra inferior + drawer "Mais". `CampaignMobileTopBar` esconde o `SidebarTrigger` via `useCampaignPageChromeRole` + `isStaffCampaignRole` (leader mantém — lockdown sem barra inferior); `CampaignSidebar` retorna `null` quando `isMobile && staff`, desmontando o Sheet; **B18 (filtros salvos de Municípios) migrou para o drawer "Mais"** — `MunicipalityNavSavedFilters` ganhou `variant: 'overflow'` (mesma store `useMunicipalitySavedFilters`, mesma lógica apagar/desfazer/sucessão de foco; no drawer o fallback de foco vira o link Perfil do footer) e `CampaignBottomNav` passou a renderizá-la entre destinos e footer (container com `overflow-y-auto`). **Bug pré-existente corrigido no caminho (D4):** o `<Toaster>` vivia dentro do wrapper `[contain:layout_paint]` do painel da AI — `contain: paint` vira containing block para `position: fixed` e cria stacking context próprio, prendendo o toast (z 999999999 do sonner) ABAIXO dos portais z-50 de drawer/sheet: qualquer toast com ação (ex. "Desfazer") era inalcançável com modal aberto; o Toaster foi movido para o root layout `(campaign)/layout.tsx` (irmão do `{children}`, direto no `<body>`). Testes: 2 unit novos na top bar (staff sem trigger / leader com trigger), 3 e2e novos (staff mobile sem trigger e sem dialog Sidebar; leader abre o sheet com "Meus contatos"; fluxo completo salvar→navegar→apagar→desfazer no drawer). Sem migration, access ou Consent. Intenção [`c102-mobile-sem-botao-sidebar.md`](plans/c102-mobile-sem-botao-sidebar.md); impl [`c102-mobile-sem-botao-sidebar-impl.md`](plans/c102-mobile-sem-botao-sidebar-impl.md).
**Recently resolved (2026-08-09):** **B179** (Issue #462) — o Sollinha passou a falar de si **no masculino**: o system prompt (`src/utilities/ai/systemPrompt.ts`) abre com "Você é **o** Sollinha" e ganhou regra explícita (auto-referência masculina em primeira pessoa, nunca flexões femininas sobre si) — o welcome da UI já era masculino; o placeholder do chat virou "Pergunte para o Sollinha..." (`CampaignAIChat.tsx`) com os seletores e2e acompanhando (2 specs). Sem migration, access, Consent ou guarda automatizada (decisão do gate: só édito de texto). Os planos entregues da família (B162/B166/B167/B173/B174/B177) permanecem frozen com "a Sollinha" histórico — o que vale é a voz no runtime e o que se escreve daqui pra frente. Intenção [`sollinha-genero-masculino.md`](plans/sollinha-genero-masculino.md); impl [`sollinha-genero-masculino-impl.md`](plans/sollinha-genero-masculino-impl.md).
**Recently resolved (2026-08-09):** **B181** (Issue #480) — a `/campanha/agenda` não exibe mais o bloco "Nenhum compromisso nesta janela e neste filtro" + botão "Criar atividade" acima do calendário em janela vazia (só ocupava espaço, redundante com slot-click C91, botão "Nova atividade" dos filtros e FAB C94). Remoção cirúrgica em `ActivityAgenda.tsx` (bloco condicional + órfãos `emptyHref`/imports `Link` e `buildActivityCreateHref`); `buildActivityCreateHref` permanece com 2 consumidores ("Mais detalhes" no inline create, "Nova atividade" nos filtros); loading/erro e `noEventsText` do FullCalendar (vista "Lista") intactos. Teste unitário de regressão: janela vazia não renderiza aviso nem link de criação. Sem migration, access, Consent ou schema. Intenção [`agenda-remover-aviso-vazio.md`](plans/agenda-remover-aviso-vazio.md); impl [`agenda-remover-aviso-vazio-impl.md`](plans/agenda-remover-aviso-vazio-impl.md).

View file

@ -0,0 +1,92 @@
# Impl: C102 — Mobile: sem botão/sheet de sidebar (navegação pela barra inferior + "Mais")
Status: aprovado
Atualizado em: 2026-08-09
Issue: #498
Intenção: docs/plans/c102-mobile-sem-botao-sidebar.md
Appetite restante: herdado (~0,5–1 dia eng; sem schema — só shell + drawer + testes)
## Leitura da intenção
- **Outcome:** no mobile (<768px), nenhuma tela `/campanha` de staff mostra o botão de abrir sidebar na barra superior e o sheet de navegação não é renderizado para staff; todos os destinos continuam alcançáveis via barra inferior + drawer "Mais"; filtros salvos de Municípios (B18) continuam acessíveis no mobile; desktop e leader inalterados.
- **O que NÃO negociar:** leader (lockdown) mantém o sheet — é a única navegação dele no mobile; desktop (≥ md) inalterado — sidebar offcanvas B38 continua; sem regressão de wizard/busca/PWA.
- **O que reavaliar:** "áreas prováveis" da intenção apontam para 4 arquivos do shell — confirmei que são os donos certos: `CampaignMobileTopBar.tsx` (único `SidebarTrigger` mobile), `CampaignSidebar.tsx` (o `<Sidebar>` que vira Sheet no mobile), `CampaignBottomNav.tsx` (drawer "Mais"), `MunicipalityNavSavedFilters.tsx` (sub-lista B18). O `layout.tsx` não muda.
## Abordagem recomendada
```mermaid
flowchart LR
A[CampaignMobileTopBar] -->|role via useCampaignPageChromeRole| B[isStaffCampaignRole?]
B -->|staff| C[sem SidebarTrigger]
B -->|leader| D[SidebarTrigger como hoje]
E[CampaignSidebar] -->|isMobile && staff| F[return null - sheet desmontado]
E -->|desktop / leader| G[sidebar/Sheet como hoje]
H[CampaignBottomNav] -->|variant overflow| I[MunicipalityNavSavedFilters no drawer Mais]
I -->|mesma store localStorage| J[links + apagar + desfazer]
```
**Opções consideradas:** ver decisões D1–D3.
**Recomendação:** gate de papel dentro dos componentes que já possuem o contexto (top bar) e o viewport (`CampaignSidebar`), e a sub-lista B18 migrada para o drawer "Mais" como segundo lar do mesmo componente (variante).
**Rejeitadas:** prop-drilling de `user` para a top bar (layout e N testes mudam sem necessidade); gating no `layout.tsx` (o breakpoint é cliente, exigiria wrapper novo); modificar o primitivo `Sidebar` da shadcn para "no mobile sheet" (polui o genérico para um consumidor); deixar B18 desktop-only (deixa um recurso entregue inalcançável no mobile — a intenção recomenda migrar).
### Decisões de engenharia
**D1 — onde esconder o `SidebarTrigger` no mobile.**
Opções: A) na `CampaignMobileTopBar`, lendo `useCampaignPageChromeRole()` (já envolve o componente no layout) + `isStaffCampaignRole` | B) prop `user` do layout | C) `useSidebar().isMobile` dentro da top bar.
Recomendação: **A** — o provider `CampaignPageChromeProvider role={user.role}` já existe no layout e a top bar já consome contextos dele; nenhuma mudança de contrato, testes unitários atuais (`renderTopBar` com role coordinator) continuam válidos. Alternativas rejeitadas: B porque propaga dependência de layout e quebra os unit tests de render sem props; C porque `useSidebar` exige `SidebarProvider` e duplica um sinal que o papel já carrega.
**D2 — como desmontar o sheet para staff no mobile.**
Opções: A) early return `null` em `CampaignSidebar` quando `isMobile && isStaffCampaignRole(user.role)` (o `useSidebar()` já fornece `isMobile`, e o `<Sidebar>` da shadcn renderiza o Sheet só `if (isMobile)`) | B) gating no layout por user-agent/viewport | C) prop no primitivo `Sidebar`.
Recomendação: **A** — dono do concern é o próprio `CampaignSidebar`; no desktop (≥768px) o rail continua exatamente como hoje; no mobile staff o Sheet nunca monta. Rejeitadas: B porque viewport é sinal de cliente e user-agent é frágil (breakpoint matchMedia é a fonte canônica no repo); C porque altera um primitivo de UI genérico para um caso.
**D3 — B18 (filtros salvos) no mobile staff.**
Opções: A) `MunicipalityNavSavedFilters` ganha `variant: 'sidebar' | 'overflow'` e o `CampaignBottomNav` renderiza a variante no drawer "Mais" (mesma store `useMunicipalitySavedFilters`, mesma lógica de apagar/desfazer/foco) | B) componente irmão novo dentro de `CampaignBottomNav.tsx` com a lógica duplicada | C) desktop-only + débito.
Recomendação: **A** — um componente, um conhecimento (linha = link + apagar + undo + sucessão de foco), dois lares; a store foi desenhada para "a barra de filtro escreve e a navegação lê" (subscribe, B18) e o drawer vira leitor extra sem nenhuma mudança de dados. Rejeitadas: B porque duplica comportamento que a auditoria do repo consolida (não "twin"); C porque a intenção recomenda A e registra B apenas como fallback.
**D4 — toast de "Desfazer" clicável com o drawer aberto (bug pré-existente exposto pelo C102).**
O e2e revelou: o `<Toaster>` vivia dentro de `CampaignAISidebarShell` → div `[contain:layout_paint]` (painel da AI). `contain: paint` vira containing block para `position: fixed` E cria stacking context próprio — o toast (z-999999999 do sonner) ficava preso ABAIXO do portal `z-50` do drawer/sheet, e o clique no "Desfazer" era interceptado pelo viewport do drawer.
Opções: A) mover `<Toaster position="top-center" />` para o layout raiz `(campaign)/layout.tsx` (irmão do `{children}`, direto no `<body>` — nenhum ancestor contain) | B) `zIndex` prop no sonner (inútil: o z-index fica preso no stacking context do contain) | C) fechar o drawer ao apagar (muda UX de snackbar).
Recomendação: **A** — correção no dono do problema (placement do toaster); o toast passa a flutuar acima de sheets/drawers em todos os fluxos `/campanha`, não só neste. Rejeitadas: B porque não resolve; C porque altera o comportamento de deleção. (Pré-existente: qualquer toast com ação era inalcançável com modal aberto — o C102 só expôs porque o drawer agora é lar de uma deleção com undo.)
### Componentes / mudanças
- **`CampaignMobileTopBar`** (`src/components/campaign/shell/CampaignMobileTopBar.tsx`): no `data-mode="app"`, renderiza `SidebarTrigger` só quando `!isStaffCampaignRole(useCampaignPageChromeRole())` (leader mantém). Wizard/home-search: inalterado (já sem trigger).
- **`CampaignSidebar`** (`src/components/campaign/shell/CampaignSidebar.tsx`): early return `null` quando `isMobile && isStaffCampaignRole(user.role)` — o Sheet mobile não monta para staff; rail desktop intacto; leader intacto.
- **`MunicipalityNavSavedFilters`** (`src/components/campaign/shell/MunicipalityNavSavedFilters.tsx`): prop `variant: 'sidebar' | 'overflow'` (default `sidebar` — `CampaignSidebar` não muda chamada). Variante `overflow`: wrapper seção (label "Filtros salvos de Municípios", `aria-label`), linhas em markup de drawer (link + botão lixeira), `onNavigate` fecha o drawer, sucessor de foco resolve para linha vizinha → último link dos destinos / link "Perfil" do footer (calculado ANTES da remoção, mesmo padrão do sidebar).
- **`CampaignBottomNav`** (`src/components/campaign/shell/CampaignBottomNav.tsx`): renderiza a variante `overflow` entre os destinos e o `DrawerFooter`; container de links ganha `min-h-0 overflow-y-auto` (lista longa + filtros não pode clipar no max-h 85dvh).
- **`(campaign)/layout.tsx`**: `<Toaster position="top-center" />` move do `(app)/layout.tsx` para o root layout (D4).
- **Migration:** sem migration (sem schema).
- **Access / Consent:** nenhum toque — papéis já chegam por `user.role`/contexto; leader lockdown intacto.
- **UI:** Impeccable B — encaixe no shell existente; sem redesign; reaproveita tokens e markup do drawer B164 e da sidebar B18.
### Dados → forma (se aplicável)
- Não apresenta dados novos — é migração de navegação. Forma da sub-lista = a mesma da sidebar (linhas link + lixeira), re-estilizada com o markup do drawer.
## Fases verificáveis
1. **Shell (trigger + sheet)** — `CampaignMobileTopBar` + `CampaignSidebar`. Verificável: unit test da top bar (staff sem trigger, leader com trigger); manual no navegador mobile.
2. **Drawer "Mais" (B18)** — variante `overflow` de `MunicipalityNavSavedFilters` + `CampaignBottomNav`. Verificável: e2e mobile salva filtro → abre "Mais" → seção visível, navega, apaga, desfaz.
3. **Gates** — `pnpm gate:fast` (lint/typecheck/format/knip/cycles/unit+int), `pnpm test:e2e` (specs de shell), `pnpm build` local; `pnpm push` → PR Ready `--base main` + `Closes #498` → auto-merge → `gh pr checks --watch --required`.
## Rabbit holes / Não escopo (engenharia)
- Não mexer no `Sidebar` da shadcn, `SidebarProvider`, `SidebarInset`, cookie `sidebar_state`, `CampaignSidebarViewportDefault` (continua inofensivo com o sheet desmontado).
- Não tocar `CampaignDesktopHeader` (trigger desktop) nem a AI sidebar (`CampaignAISidebarShell`).
- Não mudar `nav.ts` — `getCampaignOverflowNav` já cobre tudo do staff; só B18 faltava e entra pelo drawer.
- Não mexer no shortcut `cmd+b` (no staff mobile ele alterna `openMobile` sem Sheet para mostrar — no-op inofensivo).
- Não redesenhar barra inferior/drawer (B164) nem remover sidebar desktop.
## Riscos e mitigação
- **Flash de hidratação:** `useIsMobile` é `false` no primeiro render — o Sheet staff existe fechado por 1 frame até o `matchMedia` medir; sem efeito visual (fechado, portal). Mitigação: nenhuma ação; o padrão B167 (`useIsMobileMeasured`) não agrega aqui porque a diferença é invisível.
- **Foco após apagar o último filtro no drawer:** sucessor resolvido antes da remoção (linha vizinha → link "Perfil" do footer); nunca cai em `<body>` com o drawer aberto.
- **Drawer estourando 85dvh** com destinos + filtros: `overflow-y-auto` no container.
- **Regressão desktop/saved-filters e2e:** `campaignSavedFilters.e2e.spec.ts` roda no viewport desktop (1280) — sidebar continua com a sub-lista; nenhuma asserção muda.
- **Coordenação C101:** mesma top bar mobile; C102 libera o slot do hamburger; sem ordem dura — cada item entrega sozinho.
## Aceite de engenharia
- [x] Aceite de produto da intenção ainda coberto (staff mobile sem botão/sheet; destinos 100% via barra+Mais; B18 no drawer; leader e desktop intactos)
- [x] Invariantes AGENTS/engineering-standards (sem schema, sem access, sem Consent; papéis por contexto/predicado existente)
- [x] Testes previstos: unit `campaignMobileTopBar` (staff/leader) + e2e mobile (staff sem trigger/sheet, leader com sheet, B18 no drawer) — ver fases

View file

@ -23,7 +23,6 @@ import { CampaignSidebarViewportDefault } from '@/components/campaign/shell/Camp
import { CampaignWizardChromeProvider } from '@/components/campaign/shell/CampaignWizardChromeContext'
import { InstallPwaToast } from '@/components/campaign/shell/InstallPwaToast'
import { SIDEBAR_COOKIE_NAME, SidebarInset, SidebarProvider } from '@/components/ui/Sidebar'
import { Toaster } from '@/components/ui/Toaster'
import { TooltipProvider } from '@/components/ui/tooltip'
import { isStaffCampaignRole } from '@/lib/campaignRoles'
import { deviceLabelFromUserAgent } from '@/lib/deviceLabel'
@ -104,7 +103,6 @@ export default async function CampaignAppLayout({ children }: { children: React.
<CampaignBottomNav user={campaignUserShellView(user)} />
) : null}
</TooltipProvider>
<Toaster position="top-center" />
<InstallPwaToast />
{biometricEnrollment ? (
<BiometricEnrollmentToast {...biometricEnrollment} />

View file

@ -4,6 +4,7 @@ import React from 'react'
import { CampaignIosViewportHeal } from '@/components/campaign/shell/CampaignIosViewportHeal'
import { RegisterServiceWorker } from '@/components/campaign/shell/RegisterServiceWorker'
import { Toaster } from '@/components/ui/Toaster'
import { CAMPAIGN_PWA_MANIFEST_PATH, CAMPAIGN_PWA_THEME_COLOR } from '@/utilities/campaignPwa'
import '../(frontend)/styles.css'
@ -44,6 +45,11 @@ export default function CampanhaRootLayout({ children }: { children: React.React
>
<body className="bg-background bg-none text-foreground antialiased">
{children}
{/* Outside the (app) shells on purpose: the AI panel's `contain:layout
paint` wrapper would trap the toaster's `position: fixed` under the
portal z-50 of sheets/drawers, making action toasts (e.g. undo)
unclickable while a modal is open (C102). */}
<Toaster position="top-center" />
<RegisterServiceWorker />
<CampaignIosViewportHeal />
</body>

View file

@ -6,6 +6,7 @@ import { usePathname } from 'next/navigation'
import { useState, type FormEvent } from 'react'
import { logoutCampaign } from '@/app/(campaign)/campanha/actions/auth'
import { MunicipalityNavSavedFilters } from '@/components/campaign/shell/MunicipalityNavSavedFilters'
import {
getCampaignBottomNav,
getCampaignOverflowNav,
@ -115,26 +116,34 @@ export const CampaignBottomNav = ({ user }: { user: CampaignUserShellView }) =>
<DrawerDescription>Destinos da campanha</DrawerDescription>
</DrawerHeader>
<div className="flex flex-col gap-1 px-4 pb-0">
{overflowNav.map((item) => {
const active = isCampaignNavActive(pathname, item.href)
return (
<Link
key={item.href}
href={item.href}
aria-current={active ? 'page' : undefined}
onClick={() => setOverflowOpen(false)}
className={cn(
'flex items-center gap-3 rounded-md px-3 py-2.5 text-sm font-medium text-foreground outline-none',
'hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring',
active && 'bg-muted text-primary',
)}
>
<item.icon aria-hidden="true" className="size-5 shrink-0" />
<span>{item.title}</span>
</Link>
)
})}
<div className="min-h-0 flex-1 overflow-y-auto px-4 pb-2">
<div className="flex flex-col gap-1">
{overflowNav.map((item) => {
const active = isCampaignNavActive(pathname, item.href)
return (
<Link
key={item.href}
href={item.href}
aria-current={active ? 'page' : undefined}
onClick={() => setOverflowOpen(false)}
className={cn(
'flex items-center gap-3 rounded-md px-3 py-2.5 text-sm font-medium text-foreground outline-none',
'hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring',
active && 'bg-muted text-primary',
)}
>
<item.icon aria-hidden="true" className="size-5 shrink-0" />
<span>{item.title}</span>
</Link>
)
})}
</div>
{/* C102: the sheet is gone for staff on mobile — B18's saved filters
move into the overflow drawer, the new home of secondary nav. */}
<MunicipalityNavSavedFilters
variant="overflow"
onNavigate={() => setOverflowOpen(false)}
/>
</div>
<DrawerFooter>

View file

@ -6,13 +6,17 @@ import { Fragment, type ReactNode } from 'react'
import { useCampaignListTransition } from '@/components/campaign/shared/CampaignListPending'
import { CampaignWizardNavLink } from '@/components/campaign/shared/CampaignWizardNavLink'
import { useCampaignHomeSearchChrome } from '@/components/campaign/shell/CampaignHomeSearchChromeContext'
import { useCampaignHeaderActions } from '@/components/campaign/shell/CampaignPageChromeContext'
import {
useCampaignHeaderActions,
useCampaignPageChromeRole,
} from '@/components/campaign/shell/CampaignPageChromeContext'
import { CampaignPageChromeDisplay } from '@/components/campaign/shell/CampaignPageChromeDisplay'
import { useCampaignWizardChrome } from '@/components/campaign/shell/CampaignWizardChromeContext'
import { Button } from '@/components/ui/button'
import { SidebarTrigger } from '@/components/ui/Sidebar'
import { Skeleton } from '@/components/ui/skeleton'
import { HOME_SEARCH_COLLAPSE_ARIA_LABEL } from '@/lib/campaignHomeSearchContract'
import { isStaffCampaignRole } from '@/lib/campaignRoles'
import {
WIZARD_APP_TOP_BAR_ARIA_LABEL,
WIZARD_DISMISS_ARIA_LABEL,
@ -33,6 +37,7 @@ export const CampaignMobileTopBar = ({ notificationBell }: { notificationBell?:
const chrome = useCampaignWizardChrome()
const homeSearchChrome = useCampaignHomeSearchChrome()
const headerActions = useCampaignHeaderActions()
const role = useCampaignPageChromeRole()
const { isPending } = useCampaignListTransition()
if (chrome) {
@ -128,7 +133,10 @@ export const CampaignMobileTopBar = ({ notificationBell }: { notificationBell?:
<ArrowLeft className="size-4 shrink-0" aria-hidden />
Voltar
</Button>
) : (
) : isStaffCampaignRole(role) ? null : (
// C102: staff navigates from the bottom bar + "Mais" — the hamburger is
// only the leader's (lockdown, no bottom nav). The sheet itself is
// unmounted for staff in CampaignSidebar.
<SidebarTrigger className="text-primary-foreground" />
)}
<CampaignPageChromeDisplay layout="mobile" />

View file

@ -30,6 +30,7 @@ import {
} from '@/components/ui/Sidebar'
import { Spinner } from '@/components/ui/Spinner'
import { clearLastActedMunicipality } from '@/lib/campaignLastActedMunicipality'
import { isStaffCampaignRole } from '@/lib/campaignRoles'
import { clearCampaignPwaCaches } from '@/utilities/campaignPwaClient'
import type { CampaignUserShellView } from '@/utilities/campaignUserProfile'
import { campaignRoleLabels } from '@/utilities/campaignUserProfile'
@ -67,6 +68,11 @@ export const CampaignSidebar = ({ user }: { user: CampaignSidebarUser }) => {
const [isLoggingOut, setIsLoggingOut] = useState(false)
const secondaryNav = getCampaignSecondaryNav(user.role)
// C102: on mobile the staff navigates from the bottom bar + "Mais" drawer,
// so the nav sheet never mounts for staff — every destination lives below.
// The leader keeps it (lockdown, no bottom nav). Desktop rail is untouched.
if (isMobile && isStaffCampaignRole(user.role)) return null
const closeMobileSidebar = () => {
if (isMobile) setOpenMobile(false)
}

View file

@ -14,37 +14,66 @@ import {
SidebarMenuSubButton,
SidebarMenuSubItem,
} from '@/components/ui/Sidebar'
import { cn } from '@/lib/utils'
import {
removeMunicipalitySavedFilter,
saveMunicipalitySavedFilter,
type MunicipalitySavedFilter,
} from '@/utilities/municipality/municipalitySavedFilters'
const MUNICIPALITY_SAVED_FILTERS_LABEL = 'Filtros salvos de Municípios'
/**
* The saved filters of `/campanha/municipios` (B18), hung under that nav item.
* The saved filters of `/campanha/municipios` (B18), in their two homes:
*
* Renders only the sub-list: the nav link above it stays the one
* `CampaignSidebarLink` every other item uses. Always visible when entries exist
* (B124 — no disclosure).
* - `sidebar` (default): the sub-list hung under the Municípios nav item, as
* always. The nav link above it stays the one `CampaignSidebarLink` every
* other item uses.
* - `overflow` (C102): the same rows inside the mobile "Mais" drawer — the
* staff's sheet is gone, so the secondary nav lives there now.
*
* Always visible when entries exist (B124 — no disclosure). The store is
* shared (`useMunicipalitySavedFilters`), so one save repaints both homes.
*/
export const MunicipalityNavSavedFilters = ({ onNavigate }: { onNavigate: () => void }) => {
export const MunicipalityNavSavedFilters = ({
variant = 'sidebar',
onNavigate,
}: {
variant?: 'sidebar' | 'overflow'
onNavigate: () => void
}) => {
const savedFilters = useMunicipalitySavedFilters()
const activeFilter = useActiveMunicipalitySavedFilter()
if (!savedFilters.length) return null
/** Where keyboard focus lands after a removal, resolved BEFORE the row the
* button sits on unmounts. Sidebar: sibling row, then the nav link. Drawer:
* sibling row, then the next/previous section's link (footer "Perfil" or
* the last destination). */
const resolveSuccessor = (row: HTMLLIElement | null): HTMLElement | null | undefined => {
const siblingRow = row?.nextElementSibling ?? row?.previousElementSibling
if (variant === 'sidebar') {
const menuItem = row?.closest('[data-sidebar="menu-item"]')
return (
siblingRow?.querySelector<HTMLElement>('[data-sidebar="menu-sub-button"]') ??
menuItem?.querySelector<HTMLElement>('a')
)
}
const section = row?.closest('[data-saved-filters="overflow"]')
return (
siblingRow?.querySelector<HTMLElement>('a') ??
section?.nextElementSibling?.querySelector<HTMLElement>('a') ??
section?.previousElementSibling?.querySelector<HTMLElement>('a:last-of-type')
)
}
const handleRemove = (entry: MunicipalitySavedFilter, row: HTMLLIElement | null) => {
// The button about to vanish is the one holding focus, which would drop to
// <body> and strand a keyboard user at the top of the page. Resolve the
// <body> and strand a keyboard user behind the drawer overlay. Resolve the
// survivor BEFORE the removal: emptying the group unmounts this whole
// component, so the fallback has to be the nav link.
const menuItem = row?.closest('[data-sidebar="menu-item"]')
const successor =
savedFilters.length > 1
? ((row?.nextElementSibling ?? row?.previousElementSibling)?.querySelector<HTMLElement>(
'[data-sidebar="menu-sub-button"]',
) ?? menuItem?.querySelector<HTMLElement>('a'))
: menuItem?.querySelector<HTMLElement>('a')
// component, so the fallback has to live outside it.
const successor = resolveSuccessor(row)
removeMunicipalitySavedFilter(entry.href)
successor?.focus()
@ -63,8 +92,49 @@ export const MunicipalityNavSavedFilters = ({ onNavigate }: { onNavigate: () =>
})
}
if (variant === 'overflow') {
return (
<div data-saved-filters="overflow" className="border-t border-border pb-1 pt-3">
<p className="px-3 pb-1 text-xs font-medium text-muted-foreground">
{MUNICIPALITY_SAVED_FILTERS_LABEL}
</p>
<ul className="flex flex-col gap-0.5">
{savedFilters.map((entry) => {
const isActive = entry.href === activeFilter?.href
return (
<li key={entry.href} className="flex items-center gap-1">
<Link
href={entry.href}
onClick={onNavigate}
aria-current={isActive ? 'page' : undefined}
title={entry.name}
className={cn(
'flex min-w-0 flex-1 items-center gap-3 rounded-md px-3 py-2.5 text-sm font-medium text-foreground outline-none',
'hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring',
isActive && 'bg-muted text-primary',
)}
>
<span className="truncate">{entry.name}</span>
</Link>
<button
type="button"
onClick={(event) => handleRemove(entry, event.currentTarget.closest('li'))}
aria-label={`Apagar o filtro salvo ${entry.name}`}
className="mr-1 shrink-0 rounded-md p-2.5 text-muted-foreground outline-none hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
<Trash2Icon aria-hidden className="size-4" />
</button>
</li>
)
})}
</ul>
</div>
)
}
return (
<SidebarMenuSub aria-label="Filtros salvos de Municípios" className="-mt-0.5 py-0">
<SidebarMenuSub aria-label={MUNICIPALITY_SAVED_FILTERS_LABEL} className="-mt-0.5 py-0">
{savedFilters.map((entry) => {
const isActive = entry.href === activeFilter?.href

View file

@ -81,6 +81,46 @@ test.describe('Mobile bottom nav (B164)', () => {
await expect(page.locator('[aria-label="Navegação principal"]')).toHaveCount(0)
})
test('staff mobile has no sidebar trigger and no nav sheet (C102)', async ({
campaign,
page,
}) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator', {
name: fixtures.value('Coordenadora Sem Sheet'),
})
await campaign.login(page, coordinator.email!, coordinator.password)
await page.goto('/campanha')
const topBar = page.locator('[data-slot="campaign-mobile-top-bar"]')
await expect(topBar).toBeVisible()
await expect(topBar.locator('[data-slot="sidebar-trigger"]')).toHaveCount(0)
// The sheet itself is unmounted for staff — no "Sidebar" dialog exists.
await expect(page.getByRole('dialog', { name: 'Sidebar' })).toHaveCount(0)
// Every destination still arrives from below.
await expect(page.locator('[aria-label="Navegação principal"]')).toBeVisible()
})
test('leader keeps the sidebar sheet on mobile (C102)', async ({ campaign, page }) => {
const { fixtures } = campaign
const phone = fixtures.phone()
const leader = await fixtures.createCampaignUser('leader', {
name: fixtures.value('Líder Sheet'),
username: phone,
})
await campaign.login(page, phone, leader.password)
await page.goto('/campanha/contatos')
const topBar = page.locator('[data-slot="campaign-mobile-top-bar"]')
const trigger = topBar.locator('[data-slot="sidebar-trigger"]')
await expect(trigger).toBeVisible()
await trigger.click()
const sheet = page.getByRole('dialog', { name: 'Sidebar' })
await expect(sheet).toBeVisible()
await expect(sheet.getByRole('link', { name: 'Meus contatos' })).toBeVisible()
})
test('hidden on desktop viewport', async ({ campaign, page }) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator', {

View file

@ -114,4 +114,51 @@ test.describe('Filtros salvos de Municípios', () => {
await expect(page.getByRole('list', { name: 'Filtros salvos de Municípios' })).toBeVisible()
await expect(page.getByRole('button', { name: /filtros salvos de Municípios/i })).toHaveCount(0)
})
test.describe('C102 — saved filters live in the "Mais" drawer on mobile', () => {
test.use({ viewport: { width: 390, height: 844 } })
test('saves, navigates from and deletes inside the drawer', async ({ campaign, page }) => {
test.slow()
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator', {
name: fixtures.value('Coordenadora Drawer Mobile'),
})
const password = coordinator.password
const email = coordinator.email!
await campaign.login(page, email, password)
await page.goto(SAVED_RECORTE)
await page.getByRole('button', { name: 'Salvar filtro', exact: true }).click()
await page.getByLabel('Nome do filtro').fill(SAVED_NAME)
await page.getByRole('button', { name: 'Salvar', exact: true }).click()
// The mobile staff top bar has no hamburger (C102) — the drawer is the home.
const topBar = page.locator('[data-slot="campaign-mobile-top-bar"]')
await expect(topBar.locator('[data-slot="sidebar-trigger"]')).toHaveCount(0)
const bottomNav = page.locator('[aria-label="Navegação principal"]')
await bottomNav.getByRole('button', { name: 'Mais' }).click()
const drawer = page.locator('[data-slot="drawer-content"]')
await expect(drawer).toBeVisible()
await expect(drawer.getByText('Filtros salvos de Municípios')).toBeVisible()
const shortcut = drawer.getByRole('link', { name: SAVED_NAME })
await expect(shortcut).toBeVisible()
await expect(shortcut).toHaveAttribute('aria-current', 'page')
// Tapping the row restores the recorte and closes the drawer.
await shortcut.click()
await expect(page).toHaveURL(SAVED_RECORTE, NAVIGATION)
await expect(drawer).toBeHidden()
// Delete and undo keep working in the drawer.
await bottomNav.getByRole('button', { name: 'Mais' }).click()
await drawer.getByRole('button', { name: `Apagar o filtro salvo ${SAVED_NAME}` }).click()
await expect(drawer.getByRole('link', { name: SAVED_NAME })).toHaveCount(0)
await page.getByRole('button', { name: 'Desfazer' }).click()
await expect(drawer.getByRole('link', { name: SAVED_NAME })).toBeVisible()
})
})
})

View file

@ -101,6 +101,32 @@ describe('CampaignMobileTopBar', () => {
expect(collapse).toHaveBeenCalledTimes(1)
})
it('does not render the sidebar trigger for staff in app mode (C102)', () => {
const { container } = renderTopBar({})
const topBar = container.querySelector('[data-slot="campaign-mobile-top-bar"]')
expect(topBar?.getAttribute('data-mode')).toBe('app')
expect(screen.queryByRole('button', { name: 'Abrir ou fechar menu da campanha' })).toBeNull()
})
it('renders the sidebar trigger for the leader in app mode (C102)', () => {
const { container } = render(
<SidebarProvider>
<CampaignPageChromeProvider role="leader">
<CampaignWizardChromeProvider>
<CampaignHomeSearchChromeProvider>
<CampaignMobileTopBar />
</CampaignHomeSearchChromeProvider>
</CampaignWizardChromeProvider>
</CampaignPageChromeProvider>
</SidebarProvider>,
)
const topBar = container.querySelector('[data-slot="campaign-mobile-top-bar"]')
expect(topBar?.getAttribute('data-mode')).toBe('app')
expect(screen.getByRole('button', { name: 'Abrir ou fechar menu da campanha' })).toBeTruthy()
})
it('renders wizard entry mode with dismiss control and flow title', () => {
renderTopBar({
wizardChrome: {