mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
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:
commit
457aad5a21
11 changed files with 343 additions and 39 deletions
|
|
@ -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).
|
||||
|
|
|
|||
92
docs/plans/c102-mobile-sem-botao-sidebar-impl.md
Normal file
92
docs/plans/c102-mobile-sem-botao-sidebar-impl.md
Normal 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
|
||||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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', {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
Loading…
Reference in a new issue