B126 — FAB de ações rápidas substitui drawer persistente

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 <fsolla@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-08-02 18:39:27 +00:00
parent be2500afa9
commit dbce18002c
No known key found for this signature in database
13 changed files with 426 additions and 566 deletions

View file

@ -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

View file

@ -90,7 +90,7 @@ export default async function CampaignAppLayout({ children }: { children: React.
</header>
{/*
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).

View file

@ -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 <CampaignContentScroll quickActionsPeek={false}>{children}</CampaignContentScroll>
return <CampaignContentScroll>{children}</CampaignContentScroll>
}
return (
<CampaignQuickActionsSnapProvider>
<CampaignGlobalSearchProvider>
<CampaignContentScroll quickActionsPeek>{children}</CampaignContentScroll>
<CampaignQuickActionsHost role={role} />
</CampaignGlobalSearchProvider>
</CampaignQuickActionsSnapProvider>
<CampaignGlobalSearchProvider>
<CampaignContentScroll>{children}</CampaignContentScroll>
<CampaignQuickActionsHost role={role} />
</CampaignGlobalSearchProvider>
)
}

View file

@ -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 (
<Drawer
open
modal={false}
swipeDirection="down"
snapPoints={uiFocused ? [QUICK_ACTIONS_SNAP_FULL] : SNAP_POINTS}
snapPoint={uiFocused ? QUICK_ACTIONS_SNAP_FULL : snapPoint}
onSnapPointChange={handleSnapPointChange}
onOpenChange={handleOpenChange}
disablePointerDismissal
>
<DrawerContent
id="CampaignQuickActionsDrawer"
className={cn(
'border-t border-border bg-background text-foreground shadow-[0_-4px_24px_-8px_rgb(0_0_0/0.12)] print:hidden',
isFull && 'z-[60] h-dvh max-h-dvh [--drawer-height:100dvh]',
)}
>
<DrawerTitle className="sr-only">Ações rápidas</DrawerTitle>
<button
type="button"
onClick={toggleSnap}
aria-expanded={isDock || isFull}
aria-controls="quickActionContext"
className="flex w-full shrink-0 cursor-grab flex-col items-center border-0 bg-transparent px-4 pt-2 pb-0.5 active:cursor-grabbing"
aria-label={isDock || isFull ? 'Ocultar ações rápidas' : 'Mostrar ações rápidas'}
>
<span aria-hidden className="mb-1 block h-1 w-12 rounded-full bg-muted" />
</button>
<div
id="quickActionContext"
className={cn(
'flex min-h-0 flex-col overflow-y-auto px-4',
isFull
? 'flex-1 gap-2 pb-[max(0.25rem,env(safe-area-inset-bottom,0px))]'
: cn(
'gap-2 pb-[max(0.25rem,env(safe-area-inset-bottom,0px))]',
isDock ? 'flex-1' : 'shrink-0',
),
)}
data-snap={isFull ? 'full' : isDock ? 'dock' : 'collapsed'}
>
{showActions ? (
<div className="-mx-4 w-[calc(100%+2rem)]">
<CampaignHomeActionStrip
variant="strip"
actions={actions.map((action) => ({
id: action.id,
label: action.label,
icon: action.icon,
description: action.description,
href: action.href,
}))}
className="w-full"
/>
</div>
) : null}
<HomeSearchExcludeProvider value={excludeContext}>
<CampaignGlobalSearchBody
placeholder={isDock || isFull ? undefined : ''}
showResults={showSearchResults}
/>
</HomeSearchExcludeProvider>
</div>
</DrawerContent>
</Drawer>
)
}

View file

@ -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 (
<div
className={cn(
'pointer-events-none fixed bottom-4 right-4 z-40 print:hidden',
'pb-[max(0px,env(safe-area-inset-bottom))]',
className,
)}
>
<Button
type="button"
size="icon"
className="pointer-events-auto size-12 rounded-full shadow-lg"
aria-label="Ações rápidas"
onClick={() => onOpenChange(true)}
>
<Zap className="size-5" aria-hidden />
</Button>
</div>
)
}

View file

@ -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<HTMLDivElement>(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 (
<div
ref={scrollRef}
data-slot="campaign-content-scroll"
className={cn(
'min-h-0 flex-1 overflow-y-auto overscroll-contain p-4 md:p-6 print:h-auto print:overflow-visible print:p-0',
'pb-[calc(1rem+var(--campaign-quick-actions-peek))] md:pb-6',
)}
style={{ '--campaign-quick-actions-peek': peekHeight } as React.CSSProperties}
>
{children}
</div>
)
}
export const CampaignContentScroll = ({
children,
quickActionsPeek,
}: {
children: ReactNode
quickActionsPeek: boolean
}) => {
if (!quickActionsPeek) {
return (
<div
data-slot="campaign-content-scroll"
className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-4 md:p-6 print:h-auto print:overflow-visible print:p-0"
>
{children}
</div>
)
}
return <CampaignContentScrollWithPeek>{children}</CampaignContentScrollWithPeek>
}
export const CampaignContentScroll = ({ children }: { children: ReactNode }) => (
<div
data-slot="campaign-content-scroll"
className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-4 md:p-6 print:h-auto print:overflow-visible print:p-0"
>
{children}
</div>
)
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 <CampaignQuickActionsDrawer actions={actions} />
return (
<>
<CampaignQuickActionsFab open={open} onOpenChange={setOpen} />
<CampaignQuickActionsOverlay open={open} onOpenChange={setOpen} actions={actions} />
</>
)
}
export const useQuickActionsChromeActive = (role: CampaignRole): boolean => {
const pathname = usePathname()
const isMobile = useIsMobile()
return isMobile && shouldMountQuickActionsDrawer(pathname, role)
return shouldMountQuickActionsFab(pathname, role)
}

View file

@ -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 (
<div
data-slot="quick-actions-chrome"
data-retracted={retracted || undefined}
className={overlayRetractionClass(retracted)}
aria-hidden={retracted || undefined}
>
<div className="min-h-0 overflow-hidden">
<div className="-mx-4 w-[calc(100%+2rem)] md:mx-0 md:w-auto">
<CampaignHomeActionStrip
variant="strip"
actions={actions.map((action) => ({
id: action.id,
label: action.label,
icon: action.icon,
description: action.description,
href: action.href,
}))}
className="w-full"
/>
</div>
</div>
</div>
)
}
const OverlaySearchChrome = ({ className }: { className?: string }) => (
<div data-slot="quick-actions-search" className={cn('min-w-0', className)}>
<CampaignGlobalSearchBody />
</div>
)
const CampaignQuickActionsOverlayBody = ({
actions,
}: {
actions: readonly CampaignQuickAction[]
}) => {
const { uiFocused: focused } = useHomeSearch()
return (
<div className="flex min-h-0 flex-col">
<div className="order-1 md:order-2">
<OverlayActionsChrome actions={actions} retracted={focused} />
</div>
<OverlaySearchChrome
className={cn('order-2 min-w-0 md:order-1', focused ? 'mt-0' : 'mt-4 md:mt-0 md:mb-4')}
/>
</div>
)
}
const CampaignQuickActionsOverlayContent = ({
actions,
}: {
actions: readonly CampaignQuickAction[]
}) => {
const pathname = usePathname()
const { context } = useCampaignQuickActionContext()
const excludeContext = resolveHomeSearchExcludeContext(pathname, context)
return (
<HomeSearchExcludeProvider value={excludeContext}>
<CampaignQuickActionsOverlayBody actions={actions} />
</HomeSearchExcludeProvider>
)
}
export const CampaignQuickActionsOverlay = ({
open,
onOpenChange,
actions,
}: {
open: boolean
onOpenChange: (open: boolean) => void
actions: readonly CampaignQuickAction[]
}) => {
const isMobile = useIsMobile()
if (isMobile) {
return (
<Drawer open={open} onOpenChange={onOpenChange}>
<DrawerContent
id="CampaignQuickActionsOverlay"
className="max-h-[85dvh] border-t border-border bg-background text-foreground"
>
<DrawerTitle className="sr-only">Ações rápidas</DrawerTitle>
<DrawerDescription className="sr-only">
Ações do contexto e busca na campanha
</DrawerDescription>
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto px-4 pb-[max(0.75rem,env(safe-area-inset-bottom))]">
<CampaignQuickActionsOverlayContent actions={actions} />
</div>
</DrawerContent>
</Drawer>
)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
id="CampaignQuickActionsOverlay"
className="flex max-h-[min(85dvh,40rem)] w-[calc(100vw-2rem)] max-w-lg flex-col gap-4 overflow-hidden p-4 sm:p-6"
>
<DialogHeader className="sr-only">
<DialogTitle>Ações rápidas</DialogTitle>
<DialogDescription>Ações do contexto e busca na campanha</DialogDescription>
</DialogHeader>
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto">
<CampaignQuickActionsOverlayContent actions={actions} />
</div>
</DialogContent>
</Dialog>
)
}

View file

@ -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<SetStateAction<QuickActionsSnapPoint | null>>
isDock: boolean
isFull: boolean
}
const CampaignQuickActionsSnapContext = createContext<CampaignQuickActionsSnapContextValue | null>(
null,
)
export const CampaignQuickActionsSnapProvider = ({ children }: { children: ReactNode }) => {
const pathname = usePathname()
const [snapPoint, setSnapPoint] = useState<QuickActionsSnapPoint | null>(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 (
<CampaignQuickActionsSnapContext.Provider value={value}>
{children}
</CampaignQuickActionsSnapContext.Provider>
)
}
export const useCampaignQuickActionsSnap = (): CampaignQuickActionsSnapContextValue => {
const value = useContext(CampaignQuickActionsSnapContext)
if (!value) {
throw new Error('CampaignQuickActionsSnapProvider is required')
}
return value
}

View file

@ -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.

View file

@ -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'
}

View file

@ -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()

View file

@ -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)
})
})

View file

@ -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(
<TooltipProvider delayDuration={300}>
<CampaignQuickActionContextProvider>
<CampaignQuickActionsSnapProvider>
<CampaignGlobalSearchProvider>{ui}</CampaignGlobalSearchProvider>
</CampaignQuickActionsSnapProvider>
<CampaignGlobalSearchProvider>{ui}</CampaignGlobalSearchProvider>
</CampaignQuickActionContextProvider>
</TooltipProvider>,
)
const SnapReadout = () => {
const { snapPoint } = useCampaignQuickActionsSnap()
return <div data-testid="snap">{snapPoint ?? 'null'}</div>
}
const renderOverlay = (
props: Partial<React.ComponentProps<typeof CampaignQuickActionsOverlay>> = {},
) =>
renderQuickActionsChrome(
<CampaignQuickActionsOverlay open actions={[]} onOpenChange={vi.fn()} {...props} />,
)
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(
<CampaignQuickActionsFab open={false} onOpenChange={vi.fn()} />,
)
expect(screen.getByRole('button', { name: 'Ações rápidas' })).toBeTruthy()
rerender(<CampaignQuickActionsFab open onOpenChange={vi.fn()} />)
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(<CampaignQuickActionsDrawer actions={[]} />)
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(<CampaignQuickActionsDrawer actions={[]} />)
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(
<CampaignQuickActionsDrawer
actions={[
{
id: 'test',
label: 'Registrar',
icon: BarChart3,
description: 'Teste.',
href: '/campanha',
},
]}
/>,
)
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(<CampaignQuickActionsDrawer actions={[]} />)
renderOverlay()
fireEvent.change(screen.getByLabelText('Buscar na campanha'), { target: { value: 'cairu' } })
@ -197,7 +184,7 @@ describe('CampaignQuickActionsDrawer (B105)', () => {
payload: suggestWithPriorityPayload,
})
renderQuickActionsChrome(<CampaignQuickActionsDrawer actions={[]} />)
renderOverlay()
fireEvent.focus(screen.getByLabelText('Buscar na campanha'))
@ -224,7 +211,7 @@ describe('CampaignQuickActionsDrawer (B105)', () => {
return { ok: true, payload: idleSuggestPayload }
})
renderQuickActionsChrome(<CampaignQuickActionsDrawer actions={[]} />)
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(<CampaignQuickActionsDrawer actions={[]} />)
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(<CampaignQuickActionsDrawer actions={[]} />)
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(<CampaignQuickActionsDrawer actions={[]} />)
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(<CampaignQuickActionsDrawer actions={[]} />)
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(
<>
<CampaignContentScroll quickActionsPeek>
<div style={{ height: '2000px' }} />
</CampaignContentScroll>
<SnapReadout />
</>,
)
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(<CampaignQuickActionsHost role="coordinator" />)
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()
})
})