From dbce18002ceae9b71261dc7e27ac993a269c503d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 2 Aug 2026 18:39:27 +0000 Subject: [PATCH] =?UTF-8?q?B126=20=E2=80=94=20FAB=20de=20a=C3=A7=C3=B5es?= =?UTF-8?q?=20r=C3=A1pidas=20substitui=20drawer=20persistente?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove o bottom drawer não-modal (snap/peek/scroll-coupling) e introduz um FAB em todas as larguras fora do Início e wizards. Abrir o FAB mostra overlay modal: Dialog no desktop (busca em cima) e Drawer no mobile (ações em cima), reutilizando strip e busca globais do Início. Closes #260 Co-authored-by: Francisco Solla --- ...ab-acoes-rapidas-substituir-drawer-impl.md | 75 +++++ src/app/(campaign)/campanha/(app)/layout.tsx | 2 +- .../shell/CampaignAppScrollChrome.tsx | 13 +- .../shell/CampaignQuickActionsDrawer.tsx | 161 ----------- .../shell/CampaignQuickActionsFab.tsx | 38 +++ .../shell/CampaignQuickActionsHost.tsx | 120 ++------ .../shell/CampaignQuickActionsOverlay.tsx | 153 ++++++++++ .../shell/CampaignQuickActionsSnapContext.tsx | 64 ----- src/lib/campaignQuickActionMount.ts | 5 +- src/lib/campaignQuickActionSnap.ts | 42 --- tests/e2e/campaignMunicipalities.e2e.spec.ts | 2 +- tests/unit/campaignQuickAction.unit.spec.ts | 49 ++-- .../campaignQuickActionsDrawer.unit.spec.tsx | 268 +++++++----------- 13 files changed, 426 insertions(+), 566 deletions(-) create mode 100644 docs/plans/fab-acoes-rapidas-substituir-drawer-impl.md delete mode 100644 src/components/campaign/shell/CampaignQuickActionsDrawer.tsx create mode 100644 src/components/campaign/shell/CampaignQuickActionsFab.tsx create mode 100644 src/components/campaign/shell/CampaignQuickActionsOverlay.tsx delete mode 100644 src/components/campaign/shell/CampaignQuickActionsSnapContext.tsx delete mode 100644 src/lib/campaignQuickActionSnap.ts diff --git a/docs/plans/fab-acoes-rapidas-substituir-drawer-impl.md b/docs/plans/fab-acoes-rapidas-substituir-drawer-impl.md new file mode 100644 index 00000000..93d31e43 --- /dev/null +++ b/docs/plans/fab-acoes-rapidas-substituir-drawer-impl.md @@ -0,0 +1,75 @@ +# Impl: FAB de ações rápidas (substituir bottom drawer persistente) + +Status: aprovado +Atualizado em: 2026-08-02 +Issue: #260 +Intenção: docs/plans/fab-acoes-rapidas-substituir-drawer.md +Appetite restante: herdado (~1d) + +## Leitura da intenção + +- **Outcome:** drawer persistente some; FAB em todas as larguras fora do Início/wizards abre overlay modal com ações contextuais + busca no ritual do Início por breakpoint. +- **O que NÃO negociar:** catálogo contextual por rota; lockdown leader; sem FAB no Início nem `/campanha/acoes/*`; sem snap/peek/scroll-coupling; sem migration/RBAC. +- **O que reavaliar:** manter `CampaignQuickActionsDrawer` como nome — renomear para `CampaignQuickActionsOverlay`; snap context e `campaignQuickActionSnap.ts` saem por completo. + +## Abordagem recomendada + +```mermaid +flowchart LR + Host["CampaignQuickActionsHost"] + Host --> Fab["CampaignQuickActionsFab"] + Host --> Overlay["CampaignQuickActionsOverlay"] + Overlay -->|lt-md| Drawer["Drawer modal"] + Overlay -->|md+| Dialog["Dialog modal"] + Drawer --> Layout["actions↑ search↓ (mobile)"] + Dialog --> Layout2["search↑ actions↓ (desktop)"] +``` + +**Opções consideradas:** A) um único Drawer em todas as larguras | B) Sheet lateral desktop | C) Dialog md+ + Drawer mobile espelhando `CampaignHomeLayout` +**Recomendação:** **C** — ritual do Início já codificado em order classes; overlay sob demanda sem estados intermediários. +**Rejeitadas:** A (desktop drawer inferior compete com sidebar); B (chrome paralelo). + +### Componentes / mudanças + +- **`campaignQuickActionMount.ts`:** renomear `shouldMountQuickActionsDrawer` → `shouldMountQuickActionsFab`; remover gate mobile do comentário. +- **`CampaignQuickActionsOverlay.tsx`** (substitui `CampaignQuickActionsDrawer.tsx`): conteúdo ações+busca; `useIsMobile` escolhe Drawer vs Dialog; ordem espelha `CampaignHomeLayout`; retração de ações quando `uiFocused`. +- **`CampaignQuickActionsFab.tsx`:** botão flutuante fixo; abre/fecha overlay; some quando overlay aberto. +- **`CampaignQuickActionsHost.tsx`:** estado `open`; fecha em `pathname` change; remove peek scroll (`CampaignContentScrollWithPeek`). +- **`CampaignAppScrollChrome.tsx`:** remove `CampaignQuickActionsSnapProvider`; `GlobalSearchProvider` só quando FAB elegível. +- **Deletar:** `CampaignQuickActionsSnapContext.tsx`, `campaignQuickActionSnap.ts`. +- **Migration:** sem migration. +- **UI:** Impeccable C — FAB discreto `bottom-4 right-4`, safe-area; Dialog `max-w-lg`; Drawer `max-h-[85dvh]`. + +### Dados → forma + +N/A — chrome de navegação. + +## Fases verificáveis + +1. **Remover snap/peek** — deletar snap modules; simplificar scroll chrome. +2. **Overlay + FAB** — novo overlay/dialog; host wiring; mount em todas as larguras. +3. **Tests + gates** — reescrever unit drawer→overlay; atualizar mount spec; `pnpm gate:fast`; `pnpm push`. + +## Rabbit holes / Não escopo (engenharia) + +- Refatorar `Drawer.tsx` além do que snap removal naturalmente limpa (snapPoints ainda usados por outros? — não, só quick actions; manter API do kit). +- FAB no Início ou unificar ritual Início. +- Grid 2×3 no overlay (permanece strip). + +## Riscos e mitigação + +- **Provider Tooltip (B102):** manter `TooltipProvider` no layout `(app)` — overlay continua sibling do scroll. +- **Teclado mobile no Dialog:** não usamos Dialog no mobile — Drawer modal cobre. +- **Testes snap:** remover; cobrir open/close + ordem busca/ações + suggest POST. + +## Aceite de engenharia + +- [ ] Aceite de produto da intenção ainda coberto +- [ ] Invariantes AGENTS/engineering-standards +- [ ] Testes de domínio previstos (unit) onde mount/overlay mudam + +## Débitos deferidos (autônomo) + +- Dialog desktop `max-w-lg` pode precisar polish se resultados de busca ficarem apertados — gatilho: feedback de uso pós-merge. + +Self-score decision-quality: 5/5 diff --git a/src/app/(campaign)/campanha/(app)/layout.tsx b/src/app/(campaign)/campanha/(app)/layout.tsx index 82ef42a0..026fc2cd 100644 --- a/src/app/(campaign)/campanha/(app)/layout.tsx +++ b/src/app/(campaign)/campanha/(app)/layout.tsx @@ -90,7 +90,7 @@ export default async function CampaignAppLayout({ children }: { children: React. {/* Provider must wrap CampaignAppScrollChrome, not only page children: - the mobile quick-actions drawer (B91/B100) mounts as a sibling of + the quick-actions FAB overlay (B126) mounts as a sibling of the scrollport and renders search hits with CampaignHoverTooltip (priority flag). Nested only around {children} left focus→suggest without a provider and crashed the page (B102). diff --git a/src/components/campaign/shell/CampaignAppScrollChrome.tsx b/src/components/campaign/shell/CampaignAppScrollChrome.tsx index 64358860..7e3b0ca5 100644 --- a/src/components/campaign/shell/CampaignAppScrollChrome.tsx +++ b/src/components/campaign/shell/CampaignAppScrollChrome.tsx @@ -8,7 +8,6 @@ import { CampaignQuickActionsHost, useQuickActionsChromeActive, } from '@/components/campaign/shell/CampaignQuickActionsHost' -import { CampaignQuickActionsSnapProvider } from '@/components/campaign/shell/CampaignQuickActionsSnapContext' import type { CampaignRole } from '@/lib/campaignRoles' export const CampaignAppScrollChrome = ({ @@ -21,15 +20,13 @@ export const CampaignAppScrollChrome = ({ const quickActionsActive = useQuickActionsChromeActive(role) if (!quickActionsActive) { - return {children} + return {children} } return ( - - - {children} - - - + + {children} + + ) } diff --git a/src/components/campaign/shell/CampaignQuickActionsDrawer.tsx b/src/components/campaign/shell/CampaignQuickActionsDrawer.tsx deleted file mode 100644 index 5a337443..00000000 --- a/src/components/campaign/shell/CampaignQuickActionsDrawer.tsx +++ /dev/null @@ -1,161 +0,0 @@ -'use client' - -import { usePathname } from 'next/navigation' -import { useCallback, useEffect } from 'react' - -import { CampaignGlobalSearchBody } from '@/components/campaign/dashboard/CampaignGlobalSearchMount' -import { CampaignHomeActionStrip } from '@/components/campaign/dashboard/CampaignHomeActionStrip' -import { useHomeSearch } from '@/components/campaign/dashboard/HomeSearchContext' -import { HomeSearchExcludeProvider } from '@/components/campaign/dashboard/HomeSearchExcludeContext' -import { useCampaignQuickActionContext } from '@/components/campaign/shell/CampaignQuickActionContext' -import { useCampaignQuickActionsSnap } from '@/components/campaign/shell/CampaignQuickActionsSnapContext' -import { Drawer, DrawerContent, DrawerTitle } from '@/components/ui/Drawer' -import { - QUICK_ACTIONS_SCROLL_COLLAPSE_THRESHOLD_PX, - QUICK_ACTIONS_SNAP_COLLAPSED, - QUICK_ACTIONS_SNAP_DOCK, - QUICK_ACTIONS_SNAP_FULL, - QUICK_ACTIONS_SNAP_POINTS, - quickActionsSnapIsDock, - quickActionsSnapIsFull, - type QuickActionsSnapPoint, -} from '@/lib/campaignQuickActionSnap' -import type { CampaignQuickAction } from '@/lib/campaignQuickActionTypes' -import { resolveHomeSearchExcludeContext } from '@/lib/homeSearchExcludeCurrentEntity' -import { cn } from '@/lib/utils' - -const SNAP_POINTS = [...QUICK_ACTIONS_SNAP_POINTS] - -const restoreSnapAfterSearch = (): QuickActionsSnapPoint => { - const scrollport = document.querySelector('[data-slot="campaign-content-scroll"]') - const scrollTop = scrollport instanceof HTMLElement ? scrollport.scrollTop : 0 - return scrollTop > QUICK_ACTIONS_SCROLL_COLLAPSE_THRESHOLD_PX - ? QUICK_ACTIONS_SNAP_COLLAPSED - : QUICK_ACTIONS_SNAP_DOCK -} - -export const CampaignQuickActionsDrawer = ({ - actions, -}: { - actions: readonly CampaignQuickAction[] -}) => { - const pathname = usePathname() - const { context } = useCampaignQuickActionContext() - const excludeContext = resolveHomeSearchExcludeContext(pathname, context) - const { snapPoint, setSnapPoint, isDock, isFull } = useCampaignQuickActionsSnap() - const { uiFocused } = useHomeSearch() - const showActions = actions.length > 0 && isDock && !isFull - const showSearchResults = isDock || isFull - - const handleSnapPointChange = useCallback( - (next: QuickActionsSnapPoint | string | number | null) => { - if ( - next === QUICK_ACTIONS_SNAP_COLLAPSED || - next === QUICK_ACTIONS_SNAP_DOCK || - next === QUICK_ACTIONS_SNAP_FULL - ) { - setSnapPoint(next) - return - } - setSnapPoint(QUICK_ACTIONS_SNAP_COLLAPSED) - }, - [setSnapPoint], - ) - - const toggleSnap = useCallback(() => { - setSnapPoint((current) => { - if (quickActionsSnapIsFull(current)) return restoreSnapAfterSearch() - if (quickActionsSnapIsDock(current)) return QUICK_ACTIONS_SNAP_COLLAPSED - return QUICK_ACTIONS_SNAP_DOCK - }) - }, [setSnapPoint]) - - const handleOpenChange = useCallback( - (open: boolean) => { - if (!open) { - setSnapPoint(QUICK_ACTIONS_SNAP_COLLAPSED) - } - }, - [setSnapPoint], - ) - - useEffect(() => { - if (!uiFocused) return - if (!quickActionsSnapIsFull(snapPoint)) { - setSnapPoint(QUICK_ACTIONS_SNAP_FULL) - } - }, [snapPoint, setSnapPoint, uiFocused]) - - useEffect(() => { - if (uiFocused) return - setSnapPoint(restoreSnapAfterSearch()) - }, [setSnapPoint, uiFocused]) - - return ( - - - Ações rápidas - -
- {showActions ? ( -
- ({ - id: action.id, - label: action.label, - icon: action.icon, - description: action.description, - href: action.href, - }))} - className="w-full" - /> -
- ) : null} - - - -
-
-
- ) -} diff --git a/src/components/campaign/shell/CampaignQuickActionsFab.tsx b/src/components/campaign/shell/CampaignQuickActionsFab.tsx new file mode 100644 index 00000000..f163ba14 --- /dev/null +++ b/src/components/campaign/shell/CampaignQuickActionsFab.tsx @@ -0,0 +1,38 @@ +'use client' + +import { Zap } from 'lucide-react' + +import { Button } from '@/components/ui/button' +import { cn } from '@/lib/utils' + +export const CampaignQuickActionsFab = ({ + open, + onOpenChange, + className, +}: { + open: boolean + onOpenChange: (open: boolean) => void + className?: string +}) => { + if (open) return null + + return ( +
+ +
+ ) +} diff --git a/src/components/campaign/shell/CampaignQuickActionsHost.tsx b/src/components/campaign/shell/CampaignQuickActionsHost.tsx index c2f46818..1a06cb90 100644 --- a/src/components/campaign/shell/CampaignQuickActionsHost.tsx +++ b/src/components/campaign/shell/CampaignQuickActionsHost.tsx @@ -1,116 +1,48 @@ 'use client' import { usePathname } from 'next/navigation' -import { useCallback, useEffect, useRef, type ReactNode } from 'react' +import { useEffect, useState, type ReactNode } from 'react' -import { useHomeSearch } from '@/components/campaign/dashboard/HomeSearchContext' +import { CampaignQuickActionsFab } from '@/components/campaign/shell/CampaignQuickActionsFab' +import { CampaignQuickActionsOverlay } from '@/components/campaign/shell/CampaignQuickActionsOverlay' import { useCampaignQuickActionContext } from '@/components/campaign/shell/CampaignQuickActionContext' -import { CampaignQuickActionsDrawer } from '@/components/campaign/shell/CampaignQuickActionsDrawer' -import { useCampaignQuickActionsSnap } from '@/components/campaign/shell/CampaignQuickActionsSnapContext' -import { useIsMobile } from '@/hooks/use-mobile' -import { shouldMountQuickActionsDrawer } from '@/lib/campaignQuickActionMount' +import { shouldMountQuickActionsFab } from '@/lib/campaignQuickActionMount' import { resolveQuickActionsForPath } from '@/lib/campaignQuickActionRegistry' -import { - QUICK_ACTIONS_SNAP_COLLAPSED, - QUICK_ACTIONS_SNAP_DOCK, - quickActionsScrollDirection, -} from '@/lib/campaignQuickActionSnap' import type { CampaignRole } from '@/lib/campaignRoles' -import { cn } from '@/lib/utils' -const CampaignContentScrollWithPeek = ({ children }: { children: ReactNode }) => { - const scrollRef = useRef(null) - const lastScrollTopRef = useRef(0) - const { isDock, isFull, setSnapPoint } = useCampaignQuickActionsSnap() - const { uiFocused } = useHomeSearch() - const peekHeight = isFull - ? '0px' - : isDock - ? QUICK_ACTIONS_SNAP_DOCK - : QUICK_ACTIONS_SNAP_COLLAPSED - - const syncSnapFromScroll = useCallback(() => { - const scrollport = scrollRef.current - if (!scrollport) return - - const nextTop = scrollport.scrollTop - const direction = quickActionsScrollDirection(lastScrollTopRef.current, nextTop) - lastScrollTopRef.current = nextTop - - // Keep the scroll baseline fresh while search is focused; only gate snap writes. - if (uiFocused) return - - if (direction === 'down') { - setSnapPoint(QUICK_ACTIONS_SNAP_COLLAPSED) - return - } - if (direction === 'up') { - setSnapPoint(QUICK_ACTIONS_SNAP_DOCK) - } - }, [setSnapPoint, uiFocused]) - - useEffect(() => { - const scrollport = scrollRef.current - if (!scrollport) return - - lastScrollTopRef.current = scrollport.scrollTop - scrollport.addEventListener('scroll', syncSnapFromScroll, { passive: true }) - return () => { - scrollport.removeEventListener('scroll', syncSnapFromScroll) - } - }, [syncSnapFromScroll]) - - return ( -
- {children} -
- ) -} - -export const CampaignContentScroll = ({ - children, - quickActionsPeek, -}: { - children: ReactNode - quickActionsPeek: boolean -}) => { - if (!quickActionsPeek) { - return ( -
- {children} -
- ) - } - - return {children} -} +export const CampaignContentScroll = ({ children }: { children: ReactNode }) => ( +
+ {children} +
+) export const CampaignQuickActionsHost = ({ role }: { role: CampaignRole }) => { const pathname = usePathname() - const isMobile = useIsMobile() + const [open, setOpen] = useState(false) const { context } = useCampaignQuickActionContext() - const mounted = isMobile && shouldMountQuickActionsDrawer(pathname, role) + const mounted = shouldMountQuickActionsFab(pathname, role) + + useEffect(() => { + setOpen(false) + }, [pathname]) + if (!mounted) return null const actions = resolveQuickActionsForPath(pathname, role, context) - return + return ( + <> + + + + ) } export const useQuickActionsChromeActive = (role: CampaignRole): boolean => { const pathname = usePathname() - const isMobile = useIsMobile() - return isMobile && shouldMountQuickActionsDrawer(pathname, role) + return shouldMountQuickActionsFab(pathname, role) } diff --git a/src/components/campaign/shell/CampaignQuickActionsOverlay.tsx b/src/components/campaign/shell/CampaignQuickActionsOverlay.tsx new file mode 100644 index 00000000..3bb8ed46 --- /dev/null +++ b/src/components/campaign/shell/CampaignQuickActionsOverlay.tsx @@ -0,0 +1,153 @@ +'use client' + +import { usePathname } from 'next/navigation' + +import { CampaignGlobalSearchBody } from '@/components/campaign/dashboard/CampaignGlobalSearchMount' +import { CampaignHomeActionStrip } from '@/components/campaign/dashboard/CampaignHomeActionStrip' +import { useHomeSearch } from '@/components/campaign/dashboard/HomeSearchContext' +import { HomeSearchExcludeProvider } from '@/components/campaign/dashboard/HomeSearchExcludeContext' +import { useCampaignQuickActionContext } from '@/components/campaign/shell/CampaignQuickActionContext' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Drawer, DrawerContent, DrawerDescription, DrawerTitle } from '@/components/ui/Drawer' +import { useIsMobile } from '@/hooks/use-mobile' +import type { CampaignQuickAction } from '@/lib/campaignQuickActionTypes' +import { resolveHomeSearchExcludeContext } from '@/lib/homeSearchExcludeCurrentEntity' +import { cn } from '@/lib/utils' + +const overlayRetractionClass = (retracted: boolean) => + cn( + 'grid min-h-0 transition-[grid-template-rows,opacity] duration-[220ms] ease-out motion-reduce:transition-none', + retracted + ? 'grid-rows-[0fr] opacity-0 pointer-events-none motion-reduce:opacity-0' + : 'grid-rows-[1fr] opacity-100 motion-reduce:opacity-100', + ) + +const OverlayActionsChrome = ({ + actions, + retracted, +}: { + actions: readonly CampaignQuickAction[] + retracted: boolean +}) => { + if (actions.length === 0) return null + + return ( +
+
+
+ ({ + id: action.id, + label: action.label, + icon: action.icon, + description: action.description, + href: action.href, + }))} + className="w-full" + /> +
+
+
+ ) +} + +const OverlaySearchChrome = ({ className }: { className?: string }) => ( +
+ +
+) + +const CampaignQuickActionsOverlayBody = ({ + actions, +}: { + actions: readonly CampaignQuickAction[] +}) => { + const { uiFocused: focused } = useHomeSearch() + + return ( +
+
+ +
+ +
+ ) +} + +const CampaignQuickActionsOverlayContent = ({ + actions, +}: { + actions: readonly CampaignQuickAction[] +}) => { + const pathname = usePathname() + const { context } = useCampaignQuickActionContext() + const excludeContext = resolveHomeSearchExcludeContext(pathname, context) + + return ( + + + + ) +} + +export const CampaignQuickActionsOverlay = ({ + open, + onOpenChange, + actions, +}: { + open: boolean + onOpenChange: (open: boolean) => void + actions: readonly CampaignQuickAction[] +}) => { + const isMobile = useIsMobile() + + if (isMobile) { + return ( + + + Ações rápidas + + Ações do contexto e busca na campanha + +
+ +
+
+
+ ) + } + + return ( + + + + Ações rápidas + Ações do contexto e busca na campanha + +
+ +
+
+
+ ) +} diff --git a/src/components/campaign/shell/CampaignQuickActionsSnapContext.tsx b/src/components/campaign/shell/CampaignQuickActionsSnapContext.tsx deleted file mode 100644 index d2751b40..00000000 --- a/src/components/campaign/shell/CampaignQuickActionsSnapContext.tsx +++ /dev/null @@ -1,64 +0,0 @@ -'use client' - -import { usePathname } from 'next/navigation' -import { - createContext, - useContext, - useEffect, - useMemo, - useState, - type Dispatch, - type ReactNode, - type SetStateAction, -} from 'react' - -import { - QUICK_ACTIONS_SNAP_DOCK, - quickActionsSnapIsDock, - quickActionsSnapIsFull, - type QuickActionsSnapPoint, -} from '@/lib/campaignQuickActionSnap' - -type CampaignQuickActionsSnapContextValue = { - snapPoint: QuickActionsSnapPoint | null - setSnapPoint: Dispatch> - isDock: boolean - isFull: boolean -} - -const CampaignQuickActionsSnapContext = createContext( - null, -) - -export const CampaignQuickActionsSnapProvider = ({ children }: { children: ReactNode }) => { - const pathname = usePathname() - const [snapPoint, setSnapPoint] = useState(QUICK_ACTIONS_SNAP_DOCK) - - useEffect(() => { - setSnapPoint(QUICK_ACTIONS_SNAP_DOCK) - }, [pathname]) - - const value = useMemo( - (): CampaignQuickActionsSnapContextValue => ({ - snapPoint, - setSnapPoint, - isDock: quickActionsSnapIsDock(snapPoint), - isFull: quickActionsSnapIsFull(snapPoint), - }), - [snapPoint], - ) - - return ( - - {children} - - ) -} - -export const useCampaignQuickActionsSnap = (): CampaignQuickActionsSnapContextValue => { - const value = useContext(CampaignQuickActionsSnapContext) - if (!value) { - throw new Error('CampaignQuickActionsSnapProvider is required') - } - return value -} diff --git a/src/lib/campaignQuickActionMount.ts b/src/lib/campaignQuickActionMount.ts index ecd8daa2..c8ccee56 100644 --- a/src/lib/campaignQuickActionMount.ts +++ b/src/lib/campaignQuickActionMount.ts @@ -19,10 +19,9 @@ export const isLeaderContactsPath = (pathname: string): boolean => pathname === LEADER_CONTACTS_HOME || pathname.startsWith(`${LEADER_CONTACTS_HOME}/`) /** - * Whether the mobile quick-actions drawer should mount on this navigation. - * Desktop (`md+`) is gated in the host via `useIsMobile`. + * Whether the quick-actions FAB should mount on this navigation (all viewports). */ -export const shouldMountQuickActionsDrawer = (pathname: string, role: CampaignRole): boolean => { +export const shouldMountQuickActionsFab = (pathname: string, role: CampaignRole): boolean => { if (isCampaignHomePath(pathname)) return false if (isCampaignActionsPath(pathname)) return false // B84: E13 compositor already exposes primary CTAs — skip competing drawer chrome. diff --git a/src/lib/campaignQuickActionSnap.ts b/src/lib/campaignQuickActionSnap.ts deleted file mode 100644 index 0f366f81..00000000 --- a/src/lib/campaignQuickActionSnap.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** Collapsed peek — handle + discreet search (no action strip). */ -export const QUICK_ACTIONS_SNAP_COLLAPSED = '6.5rem' as const - -/** Dock — handle + action strip (2-line labels) + global search. */ -export const QUICK_ACTIONS_SNAP_DOCK = '15rem' as const - -/** Full viewport — search focus/active; covers mobile top bar (B109). */ -export const QUICK_ACTIONS_SNAP_FULL = 1 as const - -/** - * Scroll delta past this threshold on `campaign-content-scroll` collapses (↓) - * or re-docks (↑) the drawer. - */ -export const QUICK_ACTIONS_SCROLL_COLLAPSE_THRESHOLD_PX = 24 - -export const QUICK_ACTIONS_SNAP_POINTS = [ - QUICK_ACTIONS_SNAP_COLLAPSED, - QUICK_ACTIONS_SNAP_DOCK, - QUICK_ACTIONS_SNAP_FULL, -] as const - -export type QuickActionsSnapPoint = (typeof QUICK_ACTIONS_SNAP_POINTS)[number] - -export type QuickActionsScrollDirection = 'up' | 'down' | 'none' - -export const quickActionsSnapIsFull = (snap: QuickActionsSnapPoint | null): boolean => - snap === QUICK_ACTIONS_SNAP_FULL - -export const quickActionsSnapIsDock = (snap: QuickActionsSnapPoint | null): boolean => - snap === QUICK_ACTIONS_SNAP_DOCK - -/** Pure scroll-direction detector for the quick-actions peek (B105). */ -export const quickActionsScrollDirection = ( - previousScrollTop: number, - nextScrollTop: number, - thresholdPx: number = QUICK_ACTIONS_SCROLL_COLLAPSE_THRESHOLD_PX, -): QuickActionsScrollDirection => { - const delta = nextScrollTop - previousScrollTop - if (delta > thresholdPx) return 'down' - if (delta < -thresholdPx) return 'up' - return 'none' -} diff --git a/tests/e2e/campaignMunicipalities.e2e.spec.ts b/tests/e2e/campaignMunicipalities.e2e.spec.ts index b710bf9e..c12dc412 100644 --- a/tests/e2e/campaignMunicipalities.e2e.spec.ts +++ b/tests/e2e/campaignMunicipalities.e2e.spec.ts @@ -382,7 +382,7 @@ test.describe('Municípios — cards no celular (B42)', () => { await expect(page.getByRole('link', { name: 'Abrir município' })).toHaveCount(0) await card.getByRole('button', { name: 'Editar tendência política' }).click() - // B79 keeps CampaignQuickActionsDrawer mounted (modal=false); scope to the trend dialog. + // B126 FAB overlay is closed by default; scope to the trend dialog. const drawer = page.getByRole('dialog', { name: 'Editar tendência' }) await expect(drawer).toBeVisible() await expect(drawer.getByText(municipality.name, { exact: true })).toBeVisible() diff --git a/tests/unit/campaignQuickAction.unit.spec.ts b/tests/unit/campaignQuickAction.unit.spec.ts index b27deeb3..83e89c39 100644 --- a/tests/unit/campaignQuickAction.unit.spec.ts +++ b/tests/unit/campaignQuickAction.unit.spec.ts @@ -23,7 +23,7 @@ import { isCampaignActionsPath, isCampaignHomePath, isLeaderContactsPath, - shouldMountQuickActionsDrawer, + shouldMountQuickActionsFab, } from '@/lib/campaignQuickActionMount' import { ACTIVITY_LIST_PATH, @@ -36,11 +36,6 @@ import { parseOrganizationQuickActionSurface, } from '@/lib/campaignQuickActionPaths' import { resolveQuickActionsForPath } from '@/lib/campaignQuickActionRegistry' -import { - QUICK_ACTIONS_SNAP_COLLAPSED, - QUICK_ACTIONS_SNAP_DOCK, - quickActionsSnapIsDock, -} from '@/lib/campaignQuickActionSnap' import { resolveOrganizationQuickActions } from '@/lib/organizationQuickActions' describe('campaignQuickActionMount', () => { @@ -63,38 +58,38 @@ describe('campaignQuickActionMount', () => { }) it('mounts for staff outside Início and acoes', () => { - expect(shouldMountQuickActionsDrawer('/campanha/municipios', 'coordinator')).toBe(true) - expect(shouldMountQuickActionsDrawer('/campanha/territorios', 'coordinator')).toBe(true) - expect(shouldMountQuickActionsDrawer('/campanha/demandas', 'coordinator')).toBe(true) - expect(shouldMountQuickActionsDrawer('/campanha/demandas/foo', 'coordinator')).toBe(true) - expect(shouldMountQuickActionsDrawer('/campanha', 'coordinator')).toBe(false) - expect(shouldMountQuickActionsDrawer('/campanha/acoes/registrar-sinal', 'advisor')).toBe(false) + expect(shouldMountQuickActionsFab('/campanha/municipios', 'coordinator')).toBe(true) + expect(shouldMountQuickActionsFab('/campanha/territorios', 'coordinator')).toBe(true) + expect(shouldMountQuickActionsFab('/campanha/demandas', 'coordinator')).toBe(true) + expect(shouldMountQuickActionsFab('/campanha/demandas/foo', 'coordinator')).toBe(true) + expect(shouldMountQuickActionsFab('/campanha', 'coordinator')).toBe(false) + expect(shouldMountQuickActionsFab('/campanha/acoes/registrar-sinal', 'advisor')).toBe(false) }) it('mounts for leader only on contacts', () => { - expect(shouldMountQuickActionsDrawer('/campanha/contatos', 'leader')).toBe(true) - expect(shouldMountQuickActionsDrawer('/campanha/municipios', 'leader')).toBe(false) + expect(shouldMountQuickActionsFab('/campanha/contatos', 'leader')).toBe(true) + expect(shouldMountQuickActionsFab('/campanha/municipios', 'leader')).toBe(false) }) it('skips the E13 tour composer (B84)', () => { expect(isActivityTourComposerPath(ACTIVITY_TOUR_COMPOSER_PATH)).toBe(true) - expect(shouldMountQuickActionsDrawer(ACTIVITY_TOUR_COMPOSER_PATH, 'coordinator')).toBe(false) + expect(shouldMountQuickActionsFab(ACTIVITY_TOUR_COMPOSER_PATH, 'coordinator')).toBe(false) }) it('mounts assessores only for unrestricted roles (B87)', () => { - expect(shouldMountQuickActionsDrawer('/campanha/assessores', 'coordinator')).toBe(true) - expect(shouldMountQuickActionsDrawer('/campanha/assessores/12', 'candidate')).toBe(true) - expect(shouldMountQuickActionsDrawer('/campanha/assessores', 'advisor')).toBe(false) - expect(shouldMountQuickActionsDrawer('/campanha/assessores/12', 'advisor')).toBe(false) + expect(shouldMountQuickActionsFab('/campanha/assessores', 'coordinator')).toBe(true) + expect(shouldMountQuickActionsFab('/campanha/assessores/12', 'candidate')).toBe(true) + expect(shouldMountQuickActionsFab('/campanha/assessores', 'advisor')).toBe(false) + expect(shouldMountQuickActionsFab('/campanha/assessores/12', 'advisor')).toBe(false) }) it('mounts on dobradinhas for staff (B83)', () => { - expect(shouldMountQuickActionsDrawer('/campanha/dobradinhas', 'coordinator')).toBe(true) - expect(shouldMountQuickActionsDrawer('/campanha/dobradinhas/foo', 'advisor')).toBe(true) + expect(shouldMountQuickActionsFab('/campanha/dobradinhas', 'coordinator')).toBe(true) + expect(shouldMountQuickActionsFab('/campanha/dobradinhas/foo', 'advisor')).toBe(true) }) - it('omits drawer on perfil for leader (B90)', () => { - expect(shouldMountQuickActionsDrawer(CAMPAIGN_PROFILE_HOME, 'leader')).toBe(false) + it('omits FAB on perfil for leader (B90)', () => { + expect(shouldMountQuickActionsFab(CAMPAIGN_PROFILE_HOME, 'leader')).toBe(false) }) }) @@ -397,11 +392,3 @@ describe('campaignQuickActionDemands resolvers', () => { ).toEqual([]) }) }) - -describe('campaignQuickActionSnap', () => { - it('detects dock snap', () => { - expect(quickActionsSnapIsDock(QUICK_ACTIONS_SNAP_DOCK)).toBe(true) - expect(quickActionsSnapIsDock(QUICK_ACTIONS_SNAP_COLLAPSED)).toBe(false) - expect(quickActionsSnapIsDock(null)).toBe(false) - }) -}) diff --git a/tests/unit/campaignQuickActionsDrawer.unit.spec.tsx b/tests/unit/campaignQuickActionsDrawer.unit.spec.tsx index a261b2ca..a4d03675 100644 --- a/tests/unit/campaignQuickActionsDrawer.unit.spec.tsx +++ b/tests/unit/campaignQuickActionsDrawer.unit.spec.tsx @@ -5,24 +5,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { CampaignGlobalSearchProvider } from '@/components/campaign/dashboard/CampaignGlobalSearchMount' import { CampaignQuickActionContextProvider } from '@/components/campaign/shell/CampaignQuickActionContext' -import { CampaignQuickActionsDrawer } from '@/components/campaign/shell/CampaignQuickActionsDrawer' -import { CampaignContentScroll } from '@/components/campaign/shell/CampaignQuickActionsHost' -import { - CampaignQuickActionsSnapProvider, - useCampaignQuickActionsSnap, -} from '@/components/campaign/shell/CampaignQuickActionsSnapContext' +import { CampaignQuickActionsFab } from '@/components/campaign/shell/CampaignQuickActionsFab' +import { CampaignQuickActionsHost } from '@/components/campaign/shell/CampaignQuickActionsHost' +import { CampaignQuickActionsOverlay } from '@/components/campaign/shell/CampaignQuickActionsOverlay' import { TooltipProvider } from '@/components/ui/tooltip' import type { HomeSearchSuccessResponse } from '@/lib/campaignHomeSearchHits' import { postCampaignJson } from '@/lib/campaignJsonRequest' -import { - QUICK_ACTIONS_SCROLL_COLLAPSE_THRESHOLD_PX, - QUICK_ACTIONS_SNAP_COLLAPSED, - QUICK_ACTIONS_SNAP_DOCK, - QUICK_ACTIONS_SNAP_FULL, - quickActionsScrollDirection, - quickActionsSnapIsDock, - quickActionsSnapIsFull, -} from '@/lib/campaignQuickActionSnap' vi.mock('@/lib/campaignJsonRequest', () => ({ postCampaignJson: vi.fn(), @@ -87,98 +75,97 @@ afterEach(() => { /** * Mirrors campaign (app) layout: TooltipProvider wraps chrome that includes the - * drawer (B102). Nested only around page children left focus→suggest crashing. + * overlay (B126). Nested only around page children left focus→suggest crashing. */ const renderQuickActionsChrome = (ui: ReactNode) => render( - - {ui} - + {ui} , ) -const SnapReadout = () => { - const { snapPoint } = useCampaignQuickActionsSnap() - return
{snapPoint ?? 'null'}
-} +const renderOverlay = ( + props: Partial> = {}, +) => + renderQuickActionsChrome( + , + ) -describe('CampaignQuickActionsDrawer (B105)', () => { - it('loads in dock snap with handle above search and placeholder visible', () => { +describe('CampaignQuickActionsFab (B126)', () => { + it('renders when closed and hides when overlay is open', () => { + const { rerender } = render( + , + ) + expect(screen.getByRole('button', { name: 'Ações rápidas' })).toBeTruthy() + + rerender() + expect(screen.queryByRole('button', { name: 'Ações rápidas' })).toBeNull() + }) +}) + +describe('CampaignQuickActionsOverlay (B126)', () => { + it('opens desktop dialog with search above actions', () => { vi.mocked(postCampaignJson).mockResolvedValue({ ok: true, payload: idleSuggestPayload, }) - renderQuickActionsChrome() + renderOverlay({ + actions: [ + { + id: 'test', + label: 'Registrar', + icon: BarChart3, + description: 'Teste.', + href: '/campanha', + }, + ], + }) - const context = document.getElementById('quickActionContext') - expect(context?.getAttribute('data-snap')).toBe('dock') - expect(context?.hasAttribute('hidden')).toBe(false) - - const handle = screen.getByRole('button', { name: 'Ocultar ações rápidas' }) const search = screen.getByLabelText('Buscar na campanha') - expect(handle.compareDocumentPosition(search) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() + const actionsChrome = document.querySelector('[data-slot="quick-actions-chrome"]') + const searchChrome = document.querySelector('[data-slot="quick-actions-search"]') + expect(actionsChrome).not.toBeNull() + expect(searchChrome).not.toBeNull() + expect(actionsChrome?.parentElement?.className).toContain('md:order-2') + expect(searchChrome?.className).toContain('md:order-1') expect(search.getAttribute('placeholder')).toBe('Município, liderança, atividade…') expect(screen.getByRole('region', { name: 'Resultados da busca' })).toBeTruthy() }) - it('collapsed peek keeps discreet search without placeholder; actions stay hidden', () => { + it('bleeds the action strip edge-to-edge inside the overlay gutter', () => { vi.mocked(postCampaignJson).mockResolvedValue({ ok: true, payload: idleSuggestPayload, }) - renderQuickActionsChrome() - - fireEvent.click(screen.getByRole('button', { name: 'Ocultar ações rápidas' })) - expect(document.getElementById('quickActionContext')?.getAttribute('data-snap')).toBe( - 'collapsed', - ) - - const search = screen.getByLabelText('Buscar na campanha') - expect(search.getAttribute('placeholder')).toBe('') - const results = document.querySelector('[data-slot="home-search-results"]') - expect(results).toBeTruthy() - expect((results as HTMLElement).hidden).toBe(true) - expect(screen.getByRole('button', { name: 'Mostrar ações rápidas' })).toBeTruthy() - }) - - it('bleeds the action strip edge-to-edge inside the drawer gutter (B101)', () => { - vi.mocked(postCampaignJson).mockResolvedValue({ - ok: true, - payload: idleSuggestPayload, + renderOverlay({ + actions: [ + { + id: 'test', + label: 'Registrar', + icon: BarChart3, + description: 'Teste.', + href: '/campanha', + }, + ], }) - renderQuickActionsChrome( - , - ) - - const context = document.getElementById('quickActionContext') - const stripBleed = context?.firstElementChild - expect(stripBleed).not.toBeNull() + const chrome = document.querySelector('[data-slot="quick-actions-chrome"]') + const stripBleed = chrome?.firstElementChild?.firstElementChild + expect(stripBleed?.className).toContain('-mx-4') expect(stripBleed?.querySelector('[aria-label="Ações rápidas"]')).not.toBeNull() }) - it('posts home-search when the drawer query is active', async () => { + it('posts home-search when the overlay query is active', async () => { vi.mocked(postCampaignJson).mockResolvedValue({ ok: true, payload: idleSuggestPayload, }) - renderQuickActionsChrome() + renderOverlay() fireEvent.change(screen.getByLabelText('Buscar na campanha'), { target: { value: 'cairu' } }) @@ -197,7 +184,7 @@ describe('CampaignQuickActionsDrawer (B105)', () => { payload: suggestWithPriorityPayload, }) - renderQuickActionsChrome() + renderOverlay() fireEvent.focus(screen.getByLabelText('Buscar na campanha')) @@ -224,7 +211,7 @@ describe('CampaignQuickActionsDrawer (B105)', () => { return { ok: true, payload: idleSuggestPayload } }) - renderQuickActionsChrome() + renderOverlay() const input = screen.getByLabelText('Buscar na campanha') fireEvent.focus(input) @@ -245,55 +232,7 @@ describe('CampaignQuickActionsDrawer (B105)', () => { }) }) - it('expands to full snap when the peek search is focused (B109)', () => { - vi.mocked(postCampaignJson).mockResolvedValue({ - ok: true, - payload: idleSuggestPayload, - }) - - renderQuickActionsChrome() - - fireEvent.click(screen.getByRole('button', { name: 'Ocultar ações rápidas' })) - expect(document.getElementById('quickActionContext')?.getAttribute('data-snap')).toBe( - 'collapsed', - ) - - fireEvent.focus(screen.getByLabelText('Buscar na campanha')) - expect(document.getElementById('quickActionContext')?.getAttribute('data-snap')).toBe('full') - }) - - it('stays full while search remains focused even if the handle collapses (B109)', () => { - vi.mocked(postCampaignJson).mockResolvedValue({ - ok: true, - payload: idleSuggestPayload, - }) - - renderQuickActionsChrome() - - fireEvent.focus(screen.getByLabelText('Buscar na campanha')) - fireEvent.click(screen.getByRole('button', { name: 'Ocultar ações rápidas' })) - expect(document.getElementById('quickActionContext')?.getAttribute('data-snap')).toBe('full') - }) - - it('toggles between dock and collapsed from the handle', () => { - vi.mocked(postCampaignJson).mockResolvedValue({ - ok: true, - payload: idleSuggestPayload, - }) - - renderQuickActionsChrome() - - fireEvent.click(screen.getByRole('button', { name: 'Ocultar ações rápidas' })) - expect(document.getElementById('quickActionContext')?.getAttribute('data-snap')).toBe( - 'collapsed', - ) - expect(screen.getByRole('button', { name: 'Mostrar ações rápidas' })).toBeTruthy() - - fireEvent.click(screen.getByRole('button', { name: 'Mostrar ações rápidas' })) - expect(document.getElementById('quickActionContext')?.getAttribute('data-snap')).toBe('dock') - }) - - it('hides the current municipality from drawer suggest hits (B109)', async () => { + it('hides the current municipality from overlay suggest hits (B109)', async () => { vi.mocked(postCampaignJson).mockResolvedValue({ ok: true, payload: { @@ -312,7 +251,7 @@ describe('CampaignQuickActionsDrawer (B105)', () => { }, }) - renderQuickActionsChrome() + renderOverlay() fireEvent.focus(screen.getByLabelText('Buscar na campanha')) @@ -321,52 +260,59 @@ describe('CampaignQuickActionsDrawer (B105)', () => { expect(screen.queryByText('Município Atual')).toBeNull() }) }) -}) -describe('CampaignContentScroll quick-actions direction (B105)', () => { - it('collapses on scroll down and re-docks on scroll up', () => { - renderQuickActionsChrome( - <> - -
- - - , - ) + it('uses mobile drawer with actions above search', () => { + matchMediaMock.mockImplementation((query: string) => ({ + matches: query.includes('max-width'), + media: query, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })) - const scrollport = document.querySelector('[data-slot="campaign-content-scroll"]') - expect(scrollport).toBeTruthy() - expect(screen.getByTestId('snap').textContent).toBe(QUICK_ACTIONS_SNAP_DOCK) - - Object.defineProperty(scrollport, 'scrollTop', { - configurable: true, - value: QUICK_ACTIONS_SCROLL_COLLAPSE_THRESHOLD_PX + 1, + vi.mocked(postCampaignJson).mockResolvedValue({ + ok: true, + payload: idleSuggestPayload, }) - fireEvent.scroll(scrollport!) - expect(screen.getByTestId('snap').textContent).toBe(QUICK_ACTIONS_SNAP_COLLAPSED) - Object.defineProperty(scrollport, 'scrollTop', { - configurable: true, - value: 0, + renderOverlay({ + actions: [ + { + id: 'test', + label: 'Registrar', + icon: BarChart3, + description: 'Teste.', + href: '/campanha', + }, + ], }) - fireEvent.scroll(scrollport!) - expect(screen.getByTestId('snap').textContent).toBe(QUICK_ACTIONS_SNAP_DOCK) + + const actionsChrome = document.querySelector('[data-slot="quick-actions-chrome"]') + const searchChrome = document.querySelector('[data-slot="quick-actions-search"]') + expect(actionsChrome).not.toBeNull() + expect(searchChrome).not.toBeNull() + expect( + actionsChrome!.compareDocumentPosition(searchChrome!) & Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy() }) }) -describe('campaignQuickActionSnap', () => { - it('detects dock and full snaps', () => { - expect(quickActionsSnapIsDock(QUICK_ACTIONS_SNAP_DOCK)).toBe(true) - expect(quickActionsSnapIsDock(QUICK_ACTIONS_SNAP_COLLAPSED)).toBe(false) - expect(quickActionsSnapIsDock(QUICK_ACTIONS_SNAP_FULL)).toBe(false) - expect(quickActionsSnapIsDock(null)).toBe(false) - expect(quickActionsSnapIsFull(QUICK_ACTIONS_SNAP_FULL)).toBe(true) - expect(quickActionsSnapIsFull(QUICK_ACTIONS_SNAP_DOCK)).toBe(false) - }) +describe('CampaignQuickActionsHost (B126)', () => { + it('opens overlay from FAB and does not render persistent drawer chrome', () => { + vi.mocked(postCampaignJson).mockResolvedValue({ + ok: true, + payload: idleSuggestPayload, + }) - it('resolves scroll direction from delta threshold', () => { - expect(quickActionsScrollDirection(0, 25)).toBe('down') - expect(quickActionsScrollDirection(80, 50)).toBe('up') - expect(quickActionsScrollDirection(40, 50)).toBe('none') + renderQuickActionsChrome() + + expect(screen.getByRole('button', { name: 'Ações rápidas' })).toBeTruthy() + expect(document.getElementById('CampaignQuickActionsOverlay')).toBeNull() + + fireEvent.click(screen.getByRole('button', { name: 'Ações rápidas' })) + expect(document.getElementById('CampaignQuickActionsOverlay')).not.toBeNull() + expect(screen.queryByRole('button', { name: 'Ações rápidas' })).toBeNull() }) })