Merge pull request #77 from fsolla/cursor/b79-quick-actions-drawer-54a3

B79 — Chassis do bottom drawer de ações rápidas + busca global (mobile, não-modal)
This commit is contained in:
Francisco Solla 2026-07-30 23:45:02 -03:00 committed by GitHub
commit 15c6a96e1c
16 changed files with 434 additions and 49 deletions

View file

@ -1,6 +1,6 @@
# Chassis do bottom drawer de ações rápidas + busca (mobile)
Status: in-progress (Issue #15) — **fatiado 2026-07-31:** busca embutida → **B91**
Status: entregue (B79 chassis — busca → B91)
Atualizado em: 2026-07-31
Issue: #15 (B79)
Priority: P1

View file

@ -9,8 +9,10 @@ import {
BiometricEnrollmentToast,
type BiometricEnrollmentOffer,
} from '@/components/campaign/shell/BiometricEnrollmentToast'
import { CampaignAppScrollChrome } from '@/components/campaign/shell/CampaignAppScrollChrome'
import { CampaignMobileTopBar } from '@/components/campaign/shell/CampaignMobileTopBar'
import { CampaignNotificationBellSlot } from '@/components/campaign/shell/CampaignNotificationBellSlot'
import { CampaignQuickActionContextProvider } from '@/components/campaign/shell/CampaignQuickActionContext'
import { CampaignSidebar } from '@/components/campaign/shell/CampaignSidebar'
import { CampaignSidebarViewportDefault } from '@/components/campaign/shell/CampaignSidebarViewportDefault'
import { CampaignWizardChromeProvider } from '@/components/campaign/shell/CampaignWizardChromeContext'
@ -73,24 +75,25 @@ export default async function CampaignAppLayout({ children }: { children: React.
<CampaignSidebar user={campaignUserShellView(user)} />
<SidebarInset className="h-svh min-h-0 overflow-hidden print:h-auto print:overflow-visible">
<CampaignWizardChromeProvider>
<CampaignListPendingBoundary>
<CampaignMobileTopBar notificationBell={<CampaignNotificationBellSlot user={user} />} />
<header className="hidden min-h-11 shrink-0 items-center gap-2 border-b border-border px-4 md:flex print:hidden">
<SidebarTrigger />
<div className="ml-auto">
<CampaignNotificationBellSlot user={user} />
</div>
</header>
<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"
>
<TooltipProvider delayDuration={300}>{children}</TooltipProvider>
</div>
<Toaster position="top-center" />
<InstallPwaToast />
{biometricEnrollment ? <BiometricEnrollmentToast {...biometricEnrollment} /> : null}
</CampaignListPendingBoundary>
<CampaignQuickActionContextProvider>
<CampaignListPendingBoundary>
<CampaignMobileTopBar
notificationBell={<CampaignNotificationBellSlot user={user} />}
/>
<header className="hidden min-h-11 shrink-0 items-center gap-2 border-b border-border px-4 md:flex print:hidden">
<SidebarTrigger />
<div className="ml-auto">
<CampaignNotificationBellSlot user={user} />
</div>
</header>
<CampaignAppScrollChrome role={user.role}>
<TooltipProvider delayDuration={300}>{children}</TooltipProvider>
</CampaignAppScrollChrome>
<Toaster position="top-center" />
<InstallPwaToast />
{biometricEnrollment ? <BiometricEnrollmentToast {...biometricEnrollment} /> : null}
</CampaignListPendingBoundary>
</CampaignQuickActionContextProvider>
</CampaignWizardChromeProvider>
</SidebarInset>
</SidebarProvider>

View file

@ -6,14 +6,6 @@ import { CampaignHomeActions } from '@/components/campaign/dashboard/CampaignHom
import { CampaignHomeLayout } from '@/components/campaign/dashboard/CampaignHomeLayout'
import { CampaignHomeStaffChrome } from '@/components/campaign/dashboard/CampaignHomeStaffChrome'
import { CampaignHomeSummary } from '@/components/campaign/dashboard/CampaignHomeSummary'
import { HomeSearchActivityGroup } from '@/components/campaign/dashboard/HomeSearchActivityGroup'
import { HomeSearchAdvisorGroup } from '@/components/campaign/dashboard/HomeSearchAdvisorGroup'
import { HomeSearchDemandGroup } from '@/components/campaign/dashboard/HomeSearchDemandGroup'
import { HomeSearchLeadershipGroup } from '@/components/campaign/dashboard/HomeSearchLeadershipGroup'
import { HomeSearchMunicipalityGroup } from '@/components/campaign/dashboard/HomeSearchMunicipalityGroup'
import { HomeSearchResultsLayout } from '@/components/campaign/dashboard/HomeSearchResultsLayout'
import { HomeSearchResultsShell } from '@/components/campaign/dashboard/HomeSearchResultsShell'
import { HomeSearchStateDeputyGroup } from '@/components/campaign/dashboard/HomeSearchStateDeputyGroup'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { isStaffCampaignRole } from '@/lib/campaignRoles'
import { loadCampaignHomeSummary } from '@/utilities/campaignDashboardData'
@ -47,22 +39,7 @@ export default async function CampaignHomePage() {
return (
<CampaignPageShell aria-label="Início" className="h-full min-h-0">
{staff ? (
<CampaignHomeStaffChrome
actions={actions}
searchResults={
<HomeSearchResultsShell>
<HomeSearchResultsLayout>
<HomeSearchMunicipalityGroup />
<HomeSearchLeadershipGroup />
<HomeSearchAdvisorGroup />
<HomeSearchActivityGroup />
<HomeSearchStateDeputyGroup />
<HomeSearchDemandGroup />
</HomeSearchResultsLayout>
</HomeSearchResultsShell>
}
summarySlot={summarySlot}
/>
<CampaignHomeStaffChrome actions={actions} summarySlot={summarySlot} />
) : (
<CampaignHomeLayout actions={actions} />
)}

View file

@ -4,6 +4,7 @@ import type { ReactNode } from 'react'
import { CampaignHomeLayout } from '@/components/campaign/dashboard/CampaignHomeLayout'
import { CampaignHomeSearch } from '@/components/campaign/dashboard/CampaignHomeSearch'
import { CampaignStaffGlobalSearchBody } from '@/components/campaign/dashboard/CampaignStaffGlobalSearch'
import { HomeSearchProvider } from '@/components/campaign/dashboard/HomeSearchContext'
import {
HomeSearchResultsProvider,
@ -29,7 +30,7 @@ export const CampaignHomeStaffChrome = ({
summarySlot,
}: {
actions: ReactNode
/** Result groups plug in here from B48+. */
/** Result groups plug in here from B48+. Defaults to global search when omitted. */
searchResults?: ReactNode
summarySlot?: ReactNode
}) => {
@ -46,7 +47,7 @@ export const CampaignHomeStaffChrome = ({
searchResults ? (
<CampaignHomeStaffSearchSlot searchResults={searchResults} />
) : (
<CampaignHomeSearch />
<CampaignStaffGlobalSearchBody />
)
}
summarySlot={summarySlot}

View file

@ -0,0 +1,39 @@
'use client'
import { CampaignHomeSearch } from '@/components/campaign/dashboard/CampaignHomeSearch'
import { HomeSearchActivityGroup } from '@/components/campaign/dashboard/HomeSearchActivityGroup'
import { HomeSearchAdvisorGroup } from '@/components/campaign/dashboard/HomeSearchAdvisorGroup'
import { HomeSearchDemandGroup } from '@/components/campaign/dashboard/HomeSearchDemandGroup'
import { HomeSearchLeadershipGroup } from '@/components/campaign/dashboard/HomeSearchLeadershipGroup'
import { HomeSearchMunicipalityGroup } from '@/components/campaign/dashboard/HomeSearchMunicipalityGroup'
import {
HomeSearchResultsProvider,
useHomeSearchResultsState,
} from '@/components/campaign/dashboard/HomeSearchResultsContext'
import { HomeSearchResultsLayout } from '@/components/campaign/dashboard/HomeSearchResultsLayout'
import { HomeSearchResultsShell } from '@/components/campaign/dashboard/HomeSearchResultsShell'
import { HomeSearchStateDeputyGroup } from '@/components/campaign/dashboard/HomeSearchStateDeputyGroup'
const CampaignStaffGlobalSearchResults = () => {
const searchResultsState = useHomeSearchResultsState()
return (
<CampaignHomeSearch resultsBusy={searchResultsState.isFetching}>
<HomeSearchResultsProvider value={searchResultsState}>
<HomeSearchResultsShell>
<HomeSearchResultsLayout>
<HomeSearchMunicipalityGroup />
<HomeSearchLeadershipGroup />
<HomeSearchAdvisorGroup />
<HomeSearchActivityGroup />
<HomeSearchStateDeputyGroup />
<HomeSearchDemandGroup />
</HomeSearchResultsLayout>
</HomeSearchResultsShell>
</HomeSearchResultsProvider>
</CampaignHomeSearch>
)
}
/** Result groups + input — requires an ancestor `HomeSearchProvider`. */
export const CampaignStaffGlobalSearchBody = CampaignStaffGlobalSearchResults

View file

@ -0,0 +1,27 @@
'use client'
import type { ReactNode } from 'react'
import {
CampaignContentScroll,
CampaignQuickActionsHost,
useQuickActionsChromeActive,
} from '@/components/campaign/shell/CampaignQuickActionsHost'
import type { CampaignRole } from '@/lib/campaignRoles'
export const CampaignAppScrollChrome = ({
role,
children,
}: {
role: CampaignRole
children: ReactNode
}) => {
const quickActionsPeek = useQuickActionsChromeActive(role)
return (
<>
<CampaignContentScroll quickActionsPeek={quickActionsPeek}>{children}</CampaignContentScroll>
<CampaignQuickActionsHost role={role} />
</>
)
}

View file

@ -0,0 +1,40 @@
'use client'
import {
createContext,
useContext,
useMemo,
useState,
type Dispatch,
type ReactNode,
type SetStateAction,
} from 'react'
import {
emptyCampaignQuickActionContext,
type CampaignQuickActionContext,
} from '@/lib/campaignQuickActionContext'
type CampaignQuickActionContextValue = {
context: CampaignQuickActionContext
setContext: Dispatch<SetStateAction<CampaignQuickActionContext>>
}
const QuickActionContext = createContext<CampaignQuickActionContextValue | null>(null)
export const CampaignQuickActionContextProvider = ({ children }: { children: ReactNode }) => {
const [context, setContext] = useState<CampaignQuickActionContext>(
emptyCampaignQuickActionContext,
)
const value = useMemo(() => ({ context, setContext }), [context])
return <QuickActionContext.Provider value={value}>{children}</QuickActionContext.Provider>
}
export const useCampaignQuickActionContext = (): CampaignQuickActionContextValue => {
const value = useContext(QuickActionContext)
if (!value) {
throw new Error('CampaignQuickActionContextProvider is required')
}
return value
}

View file

@ -0,0 +1,110 @@
'use client'
import { useCallback, useState } from 'react'
import { CampaignHomeActionStrip } from '@/components/campaign/dashboard/CampaignHomeActionStrip'
import { Drawer, DrawerContent, DrawerTitle } from '@/components/ui/Drawer'
import type { ResolvedCampaignHomeAction } from '@/lib/campaignHomeActions'
import {
QUICK_ACTIONS_SNAP_COLLAPSED,
QUICK_ACTIONS_SNAP_EXPANDED,
QUICK_ACTIONS_SNAP_POINTS,
quickActionsSnapIsExpanded,
type QuickActionsSnapPoint,
} from '@/lib/campaignQuickActionSnap'
import { cn } from '@/lib/utils'
const SNAP_POINTS = [...QUICK_ACTIONS_SNAP_POINTS]
export const CampaignQuickActionsDrawer = ({
actions,
}: {
actions: readonly ResolvedCampaignHomeAction[]
}) => {
const [snapPoint, setSnapPoint] = useState<QuickActionsSnapPoint | null>(
QUICK_ACTIONS_SNAP_COLLAPSED,
)
const expanded = quickActionsSnapIsExpanded(snapPoint)
const showActions = actions.length > 0
const handleSnapPointChange = useCallback(
(next: QuickActionsSnapPoint | string | number | null) => {
if (next === QUICK_ACTIONS_SNAP_COLLAPSED || next === QUICK_ACTIONS_SNAP_EXPANDED) {
setSnapPoint(next)
return
}
setSnapPoint(QUICK_ACTIONS_SNAP_COLLAPSED)
},
[],
)
const toggleSnap = useCallback(() => {
setSnapPoint((current) =>
quickActionsSnapIsExpanded(current)
? QUICK_ACTIONS_SNAP_COLLAPSED
: QUICK_ACTIONS_SNAP_EXPANDED,
)
}, [])
const handleOpenChange = useCallback((open: boolean) => {
if (!open) {
setSnapPoint(QUICK_ACTIONS_SNAP_COLLAPSED)
}
}, [])
return (
<Drawer
open
modal={false}
swipeDirection="down"
snapPoints={SNAP_POINTS}
snapPoint={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',
!expanded && 'pointer-events-auto',
)}
>
<DrawerTitle className="sr-only">Ações rápidas</DrawerTitle>
<button
type="button"
onClick={toggleSnap}
aria-expanded={expanded}
aria-controls="quickActionContext"
className="flex w-full shrink-0 cursor-grab flex-col items-center border-0 bg-transparent px-4 pt-2 pb-1 active:cursor-grabbing"
aria-label={expanded ? '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-1 flex-col gap-4 overflow-y-auto overscroll-contain px-4 pb-[max(1rem,env(safe-area-inset-bottom,0px))]',
!expanded && 'hidden',
)}
data-snap={expanded ? 'expanded' : 'collapsed'}
>
{showActions ? (
<CampaignHomeActionStrip
actions={actions.map((action) => ({
id: action.id,
label: action.label,
icon: action.icon,
description: action.description,
href: action.href,
}))}
className="w-full"
/>
) : (
<p className="text-sm text-muted-foreground">Nenhuma ação rápida nesta página.</p>
)}
</div>
</DrawerContent>
</Drawer>
)
}

View file

@ -0,0 +1,55 @@
'use client'
import { usePathname } from 'next/navigation'
import type { ReactNode } from 'react'
import { useCampaignQuickActionContext } from '@/components/campaign/shell/CampaignQuickActionContext'
import { CampaignQuickActionsDrawer } from '@/components/campaign/shell/CampaignQuickActionsDrawer'
import { useIsMobile } from '@/hooks/use-mobile'
import { shouldMountQuickActionsDrawer } from '@/lib/campaignQuickActionMount'
import { resolveQuickActionsForPath } from '@/lib/campaignQuickActionRegistry'
import { QUICK_ACTIONS_SNAP_COLLAPSED } from '@/lib/campaignQuickActionSnap'
import type { CampaignRole } from '@/lib/campaignRoles'
import { cn } from '@/lib/utils'
export const CampaignContentScroll = ({
children,
quickActionsPeek,
}: {
children: ReactNode
quickActionsPeek: boolean
}) => (
<div
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',
quickActionsPeek && 'pb-[calc(1rem+var(--campaign-quick-actions-peek))] md:pb-6',
)}
style={
quickActionsPeek
? ({ '--campaign-quick-actions-peek': QUICK_ACTIONS_SNAP_COLLAPSED } as React.CSSProperties)
: undefined
}
>
{children}
</div>
)
export const CampaignQuickActionsHost = ({ role }: { role: CampaignRole }) => {
const pathname = usePathname()
const isMobile = useIsMobile()
const { context } = useCampaignQuickActionContext()
const mounted = isMobile && shouldMountQuickActionsDrawer(pathname, role)
if (!mounted) return null
const actions = resolveQuickActionsForPath(pathname, role, context)
return <CampaignQuickActionsDrawer actions={actions} />
}
export const useQuickActionsChromeActive = (role: CampaignRole): boolean => {
const pathname = usePathname()
const isMobile = useIsMobile()
return isMobile && shouldMountQuickActionsDrawer(pathname, role)
}

View file

@ -0,0 +1,13 @@
/**
* Client-safe quick-action context — routes and B80+ providers fill this so
* `resolveQuickActionsForPath` can prefill wizard/list hrefs.
*/
export type CampaignQuickActionContext = {
municipalitySlug?: string
leadershipId?: number
organizationSlug?: string
activitySlug?: string
demandSlug?: string
}
export const emptyCampaignQuickActionContext = (): CampaignQuickActionContext => ({})

View file

@ -0,0 +1,24 @@
import { CAMPAIGN_ACTIONS_HOME } from '@/lib/campaignActionRoutes'
import { CAMPAIGN_HOME, LEADER_CONTACTS_HOME } from '@/lib/campaignPaths'
import { isStaffCampaignRole, type CampaignRole } from '@/lib/campaignRoles'
/** Início matches only exactly — same rule as sidebar nav. */
export const isCampaignHomePath = (pathname: string): boolean =>
pathname === CAMPAIGN_HOME || pathname === `${CAMPAIGN_HOME}/`
export const isCampaignActionsPath = (pathname: string): boolean =>
pathname === CAMPAIGN_ACTIONS_HOME || pathname.startsWith(`${CAMPAIGN_ACTIONS_HOME}/`)
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`.
*/
export const shouldMountQuickActionsDrawer = (pathname: string, role: CampaignRole): boolean => {
if (isCampaignHomePath(pathname)) return false
if (isCampaignActionsPath(pathname)) return false
if (role === 'leader') return isLeaderContactsPath(pathname)
return isStaffCampaignRole(role)
}

View file

@ -0,0 +1,14 @@
import type { ResolvedCampaignHomeAction } from '@/lib/campaignHomeActions'
import type { CampaignQuickActionContext } from '@/lib/campaignQuickActionContext'
import type { CampaignRole } from '@/lib/campaignRoles'
/**
* Pathname + role + page context → contextual quick actions.
* Catalogs per vertical land in B80–B90; the B79 chassis renders whatever
* this helper returns (empty until those items register providers).
*/
export const resolveQuickActionsForPath = (
_pathname: string,
_role: CampaignRole,
_context: CampaignQuickActionContext,
): readonly ResolvedCampaignHomeAction[] => []

View file

@ -0,0 +1,15 @@
/** Collapsed peek — swipe handle only (replaces legacy bottom-nav `pb-24`). */
export const QUICK_ACTIONS_SNAP_COLLAPSED = '3rem' as const
/** Expanded — actions strip + global search inside the drawer. */
export const QUICK_ACTIONS_SNAP_EXPANDED = 0.55 as const
export const QUICK_ACTIONS_SNAP_POINTS = [
QUICK_ACTIONS_SNAP_COLLAPSED,
QUICK_ACTIONS_SNAP_EXPANDED,
] as const
export type QuickActionsSnapPoint = (typeof QUICK_ACTIONS_SNAP_POINTS)[number]
export const quickActionsSnapIsExpanded = (snap: QuickActionsSnapPoint | null): boolean =>
snap === QUICK_ACTIONS_SNAP_EXPANDED

View file

@ -48,8 +48,8 @@ describe('searchHomeMunicipalities (B48)', () => {
const advisor = await fixtures.createCampaignUser('advisor')
const coordinator = await fixtures.createCampaignUser('coordinator')
const administered = await fixtures.getMunicipality()
const other = await fixtures.getMunicipality()
const administered = await fixtures.getMunicipality('cairu')
const other = await fixtures.getMunicipality('feira-de-santana')
await fixtures.assignMunicipalityAdvisors(administered.id, [advisor.id])
const otherQuery = distinctWordStartQuery(other.name, administered.name)

View file

@ -124,6 +124,10 @@ describe('campaign visual foundation', () => {
resolve(process.cwd(), 'src/app/(campaign)/campanha/(app)/layout.tsx'),
'utf8',
)
const scrollChromeSource = readFileSync(
resolve(process.cwd(), 'src/components/campaign/shell/CampaignQuickActionsHost.tsx'),
'utf8',
)
const sidebarSource = readFileSync(
resolve(process.cwd(), 'src/components/campaign/shell/CampaignSidebar.tsx'),
'utf8',
@ -132,8 +136,8 @@ describe('campaign visual foundation', () => {
expect(layoutSource).toContain(
'className="h-svh min-h-0 overflow-hidden print:h-auto print:overflow-visible"',
)
expect(layoutSource).toContain('data-slot="campaign-content-scroll"')
expect(layoutSource).toContain('min-h-0 flex-1 overflow-y-auto overscroll-contain')
expect(scrollChromeSource).toContain('data-slot="campaign-content-scroll"')
expect(scrollChromeSource).toContain('min-h-0 flex-1 overflow-y-auto overscroll-contain')
expect(layoutSource).toContain(
'hidden min-h-11 shrink-0 items-center gap-2 border-b border-border px-4 md:flex print:hidden',
)

View file

@ -0,0 +1,63 @@
import { describe, expect, it } from 'vitest'
import {
isCampaignActionsPath,
isCampaignHomePath,
isLeaderContactsPath,
shouldMountQuickActionsDrawer,
} from '@/lib/campaignQuickActionMount'
import { resolveQuickActionsForPath } from '@/lib/campaignQuickActionRegistry'
import {
QUICK_ACTIONS_SNAP_COLLAPSED,
QUICK_ACTIONS_SNAP_EXPANDED,
quickActionsSnapIsExpanded,
} from '@/lib/campaignQuickActionSnap'
describe('campaignQuickActionMount', () => {
it('treats Início as exact match only', () => {
expect(isCampaignHomePath('/campanha')).toBe(true)
expect(isCampaignHomePath('/campanha/')).toBe(true)
expect(isCampaignHomePath('/campanha/municipios')).toBe(false)
})
it('matches action wizard subtree', () => {
expect(isCampaignActionsPath('/campanha/acoes')).toBe(true)
expect(isCampaignActionsPath('/campanha/acoes/atualizar-votos')).toBe(true)
expect(isCampaignActionsPath('/campanha/municipios')).toBe(false)
})
it('matches leader contacts subtree', () => {
expect(isLeaderContactsPath('/campanha/contatos')).toBe(true)
expect(isLeaderContactsPath('/campanha/contatos/novo')).toBe(true)
expect(isLeaderContactsPath('/campanha')).toBe(false)
})
it('mounts for staff outside Início and acoes', () => {
expect(shouldMountQuickActionsDrawer('/campanha/municipios', 'coordinator')).toBe(true)
expect(shouldMountQuickActionsDrawer('/campanha', 'coordinator')).toBe(false)
expect(shouldMountQuickActionsDrawer('/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)
})
})
describe('campaignQuickActionRegistry', () => {
it('returns empty catalog until B80+ register providers', () => {
expect(
resolveQuickActionsForPath('/campanha/municipios/foo', 'coordinator', {
municipalitySlug: 'foo',
}),
).toEqual([])
})
})
describe('campaignQuickActionSnap', () => {
it('detects expanded snap', () => {
expect(quickActionsSnapIsExpanded(QUICK_ACTIONS_SNAP_EXPANDED)).toBe(true)
expect(quickActionsSnapIsExpanded(QUICK_ACTIONS_SNAP_COLLAPSED)).toBe(false)
expect(quickActionsSnapIsExpanded(null)).toBe(false)
})
})