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 (
+
+ )
+}
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()
})
})