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:
parent
be2500afa9
commit
dbce18002c
13 changed files with 426 additions and 566 deletions
75
docs/plans/fab-acoes-rapidas-substituir-drawer-impl.md
Normal file
75
docs/plans/fab-acoes-rapidas-substituir-drawer-impl.md
Normal 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
|
||||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
38
src/components/campaign/shell/CampaignQuickActionsFab.tsx
Normal file
38
src/components/campaign/shell/CampaignQuickActionsFab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
153
src/components/campaign/shell/CampaignQuickActionsOverlay.tsx
Normal file
153
src/components/campaign/shell/CampaignQuickActionsOverlay.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue