diff --git a/src/components/campaign/shell/CampaignAppScrollChrome.tsx b/src/components/campaign/shell/CampaignAppScrollChrome.tsx index 8750e805..e6569f96 100644 --- a/src/components/campaign/shell/CampaignAppScrollChrome.tsx +++ b/src/components/campaign/shell/CampaignAppScrollChrome.tsx @@ -2,11 +2,16 @@ import type { ReactNode } from 'react' +import { CampaignGlobalSearchProvider } from '@/components/campaign/dashboard/CampaignGlobalSearchMount' import { CampaignContentScroll, CampaignQuickActionsHost, useQuickActionsChromeActive, } from '@/components/campaign/shell/CampaignQuickActionsHost' +import { + CampaignQuickActionsScrollCollapse, + CampaignQuickActionsSnapProvider, +} from '@/components/campaign/shell/CampaignQuickActionsSnapContext' import type { CampaignRole } from '@/lib/campaignRoles' export const CampaignAppScrollChrome = ({ @@ -16,12 +21,19 @@ export const CampaignAppScrollChrome = ({ role: CampaignRole children: ReactNode }) => { - const quickActionsPeek = useQuickActionsChromeActive(role) + const quickActionsActive = useQuickActionsChromeActive(role) + + if (!quickActionsActive) { + return {children} + } return ( - <> - {children} - - > + + + + {children} + + + ) } diff --git a/src/components/campaign/shell/CampaignQuickActionsDrawer.tsx b/src/components/campaign/shell/CampaignQuickActionsDrawer.tsx index 784818cf..f3b12df1 100644 --- a/src/components/campaign/shell/CampaignQuickActionsDrawer.tsx +++ b/src/components/campaign/shell/CampaignQuickActionsDrawer.tsx @@ -1,19 +1,19 @@ 'use client' -import { useCallback, useState } from 'react' +import { useCallback } from 'react' -import { CampaignGlobalSearchMount } from '@/components/campaign/dashboard/CampaignGlobalSearchMount' +import { CampaignGlobalSearchBody } from '@/components/campaign/dashboard/CampaignGlobalSearchMount' import { CampaignHomeActionStrip } from '@/components/campaign/dashboard/CampaignHomeActionStrip' +import { useCampaignQuickActionsSnap } from '@/components/campaign/shell/CampaignQuickActionsSnapContext' import { Drawer, DrawerContent, DrawerTitle } from '@/components/ui/Drawer' import { QUICK_ACTIONS_SNAP_COLLAPSED, - QUICK_ACTIONS_SNAP_EXPANDED, + QUICK_ACTIONS_SNAP_DOCK, QUICK_ACTIONS_SNAP_POINTS, - quickActionsSnapIsExpanded, + quickActionsSnapIsDock, type QuickActionsSnapPoint, } from '@/lib/campaignQuickActionSnap' import type { CampaignQuickAction } from '@/lib/campaignQuickActionTypes' -import { cn } from '@/lib/utils' const SNAP_POINTS = [...QUICK_ACTIONS_SNAP_POINTS] @@ -22,36 +22,34 @@ export const CampaignQuickActionsDrawer = ({ }: { actions: readonly CampaignQuickAction[] }) => { - const [snapPoint, setSnapPoint] = useState( - QUICK_ACTIONS_SNAP_COLLAPSED, - ) - const expanded = quickActionsSnapIsExpanded(snapPoint) + const { snapPoint, setSnapPoint, isDock } = useCampaignQuickActionsSnap() const showActions = actions.length > 0 const handleSnapPointChange = useCallback( (next: QuickActionsSnapPoint | string | number | null) => { - if (next === QUICK_ACTIONS_SNAP_COLLAPSED || next === QUICK_ACTIONS_SNAP_EXPANDED) { + if (next === QUICK_ACTIONS_SNAP_COLLAPSED || next === QUICK_ACTIONS_SNAP_DOCK) { setSnapPoint(next) return } setSnapPoint(QUICK_ACTIONS_SNAP_COLLAPSED) }, - [], + [setSnapPoint], ) const toggleSnap = useCallback(() => { setSnapPoint((current) => - quickActionsSnapIsExpanded(current) - ? QUICK_ACTIONS_SNAP_COLLAPSED - : QUICK_ACTIONS_SNAP_EXPANDED, + quickActionsSnapIsDock(current) ? QUICK_ACTIONS_SNAP_COLLAPSED : QUICK_ACTIONS_SNAP_DOCK, ) - }, []) + }, [setSnapPoint]) - const handleOpenChange = useCallback((open: boolean) => { - if (!open) { - setSnapPoint(QUICK_ACTIONS_SNAP_COLLAPSED) - } - }, []) + const handleOpenChange = useCallback( + (open: boolean) => { + if (!open) { + setSnapPoint(QUICK_ACTIONS_SNAP_COLLAPSED) + } + }, + [setSnapPoint], + ) return ( Ações rápidas {showActions ? ( ) : null} - {expanded ? : null} + diff --git a/src/components/campaign/shell/CampaignQuickActionsHost.tsx b/src/components/campaign/shell/CampaignQuickActionsHost.tsx index 0de35088..d411f6cf 100644 --- a/src/components/campaign/shell/CampaignQuickActionsHost.tsx +++ b/src/components/campaign/shell/CampaignQuickActionsHost.tsx @@ -5,35 +5,55 @@ import type { ReactNode } from 'react' 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 { resolveQuickActionsForPath } from '@/lib/campaignQuickActionRegistry' -import { QUICK_ACTIONS_SNAP_COLLAPSED } from '@/lib/campaignQuickActionSnap' +import { + QUICK_ACTIONS_SNAP_COLLAPSED, + QUICK_ACTIONS_SNAP_DOCK, +} from '@/lib/campaignQuickActionSnap' import type { CampaignRole } from '@/lib/campaignRoles' import { cn } from '@/lib/utils' +const CampaignContentScrollWithPeek = ({ children }: { children: ReactNode }) => { + const { isDock } = useCampaignQuickActionsSnap() + const peekHeight = isDock ? QUICK_ACTIONS_SNAP_DOCK : QUICK_ACTIONS_SNAP_COLLAPSED + + return ( + + {children} + + ) +} + export const CampaignContentScroll = ({ children, quickActionsPeek, }: { children: ReactNode quickActionsPeek: boolean -}) => ( - - {children} - -) +}) => { + if (!quickActionsPeek) { + return ( + + {children} + + ) + } + + return {children} +} export const CampaignQuickActionsHost = ({ role }: { role: CampaignRole }) => { const pathname = usePathname() diff --git a/src/components/campaign/shell/CampaignQuickActionsSnapContext.tsx b/src/components/campaign/shell/CampaignQuickActionsSnapContext.tsx new file mode 100644 index 00000000..2910d196 --- /dev/null +++ b/src/components/campaign/shell/CampaignQuickActionsSnapContext.tsx @@ -0,0 +1,96 @@ +'use client' + +import { usePathname } from 'next/navigation' +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, + type Dispatch, + type ReactNode, + type SetStateAction, +} from 'react' + +import { useHomeSearch } from '@/components/campaign/dashboard/HomeSearchContext' +import { + QUICK_ACTIONS_SCROLL_COLLAPSE_THRESHOLD_PX, + QUICK_ACTIONS_SNAP_COLLAPSED, + QUICK_ACTIONS_SNAP_DOCK, + quickActionsSnapIsDock, + type QuickActionsSnapPoint, +} from '@/lib/campaignQuickActionSnap' + +type CampaignQuickActionsSnapContextValue = { + snapPoint: QuickActionsSnapPoint | null + setSnapPoint: Dispatch> + isDock: boolean +} + +const CampaignQuickActionsSnapContext = createContext( + null, +) + +export const CampaignQuickActionsSnapProvider = ({ children }: { children: ReactNode }) => { + const pathname = usePathname() + const [snapPoint, setSnapPoint] = useState( + QUICK_ACTIONS_SNAP_DOCK, + ) + + useEffect(() => { + setSnapPoint(QUICK_ACTIONS_SNAP_DOCK) + }, [pathname]) + + const value = useMemo( + (): CampaignQuickActionsSnapContextValue => ({ + snapPoint, + setSnapPoint, + isDock: quickActionsSnapIsDock(snapPoint), + }), + [snapPoint], + ) + + return ( + + {children} + + ) +} + +export const useCampaignQuickActionsSnap = (): CampaignQuickActionsSnapContextValue => { + const value = useContext(CampaignQuickActionsSnapContext) + if (!value) { + throw new Error('CampaignQuickActionsSnapProvider is required') + } + return value +} + +/** Collapses the dock when the main content scrollport moves down past the threshold. */ +export const CampaignQuickActionsScrollCollapse = () => { + const { setSnapPoint } = useCampaignQuickActionsSnap() + const { uiFocused } = useHomeSearch() + + const collapseIfScrolled = useCallback(() => { + if (uiFocused) return + + const scrollport = document.querySelector('[data-slot="campaign-content-scroll"]') + if (!(scrollport instanceof HTMLElement)) return + + if (scrollport.scrollTop > QUICK_ACTIONS_SCROLL_COLLAPSE_THRESHOLD_PX) { + setSnapPoint(QUICK_ACTIONS_SNAP_COLLAPSED) + } + }, [setSnapPoint, uiFocused]) + + useEffect(() => { + const scrollport = document.querySelector('[data-slot="campaign-content-scroll"]') + if (!(scrollport instanceof HTMLElement)) return + + scrollport.addEventListener('scroll', collapseIfScrolled, { passive: true }) + return () => { + scrollport.removeEventListener('scroll', collapseIfScrolled) + } + }, [collapseIfScrolled]) + + return null +} diff --git a/src/components/ui/Drawer.tsx b/src/components/ui/Drawer.tsx index 3fc9ce51..b6c918da 100644 --- a/src/components/ui/Drawer.tsx +++ b/src/components/ui/Drawer.tsx @@ -137,7 +137,7 @@ const DrawerContent = ({ className, children, ...props }: DrawerPrimitive.Popup. 'data-ending-style:transform-(--closed-transform) data-ending-style:opacity-[0.9999] data-ending-style:duration-[calc(var(--drawer-swipe-strength)*400ms)] data-nested-drawer-swiping:duration-0 data-ending-style:data-nested-drawer-swiping:duration-[calc(var(--drawer-swipe-strength)*400ms)] data-starting-style:transform-(--closed-transform) data-swiping:duration-0 data-ending-style:data-swiping:duration-[calc(var(--drawer-swipe-strength)*400ms)]', 'data-[swipe-axis=y]:inset-x-0 data-[swipe-axis=y]:data-nested-drawer-open:h-(--stack-height)', 'data-[swipe-axis=x]:inset-y-0 data-[swipe-axis=x]:flex-row', - 'data-[swipe-direction=down]:bottom-0 data-[swipe-direction=down]:origin-bottom data-[swipe-direction=down]:[--closed-transform:translate3d(0,calc(100%+var(--drawer-inset,0px)+2px),0)] data-[swipe-direction=down]:[--translate-y:calc(var(--drawer-snap-point-offset,0px)+var(--drawer-swipe-movement-y)-var(--stack-peek-offset)-(var(--stack-shrink)*var(--stack-height)))]', + 'data-[swipe-direction=down]:bottom-0 data-[swipe-direction=down]:origin-bottom data-[swipe-direction=down]:[--closed-transform:translate3d(0,calc(100%+var(--drawer-inset,0px)+2px),0)] data-[swipe-direction=down]:[--translate-y:calc(var(--drawer-snap-point-offset,0px)+var(--drawer-swipe-movement-y)-var(--stack-peek-offset)-(var(--stack-shrink)*var(--stack-height)))] data-[swipe-direction=down]:data-snap-points:pb-[max(0px,calc(var(--drawer-snap-point-offset)+var(--drawer-swipe-movement-y)))] data-[swipe-direction=down]:data-snap-points:data-starting-style:pb-0 data-[swipe-direction=down]:data-snap-points:data-ending-style:pb-0', 'data-[swipe-direction=up]:top-0 data-[swipe-direction=up]:origin-top data-[swipe-direction=up]:[--closed-transform:translate3d(0,calc(-100%-var(--drawer-inset,0px)-2px),0)] data-[swipe-direction=up]:[--translate-y:calc(var(--drawer-snap-point-offset,0px)+var(--drawer-swipe-movement-y)+var(--stack-peek-offset)+(var(--stack-shrink)*var(--stack-height)))]', 'data-[swipe-direction=left]:left-0 data-[swipe-direction=left]:origin-left data-[swipe-direction=left]:[--closed-transform:translate3d(calc(-100%-var(--drawer-inset,0px)-2px),0,0)] data-[swipe-direction=left]:[--translate-x:calc(var(--drawer-swipe-movement-x)+var(--stack-peek-offset)+(var(--stack-shrink)*100%))]', 'data-[swipe-direction=right]:right-0 data-[swipe-direction=right]:origin-right data-[swipe-direction=right]:[--closed-transform:translate3d(calc(100%+var(--drawer-inset,0px)+2px),0,0)] data-[swipe-direction=right]:[--translate-x:calc(var(--drawer-swipe-movement-x)-var(--stack-peek-offset)-(var(--stack-shrink)*100%))]', diff --git a/src/lib/campaignQuickActionSnap.ts b/src/lib/campaignQuickActionSnap.ts index 7329c97e..044ca5f8 100644 --- a/src/lib/campaignQuickActionSnap.ts +++ b/src/lib/campaignQuickActionSnap.ts @@ -1,15 +1,18 @@ /** 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 +/** Dock — action strip + global search visible on load and after navigation. */ +export const QUICK_ACTIONS_SNAP_DOCK = '12rem' as const + +/** Scroll down past this threshold on `campaign-content-scroll` collapses the drawer. */ +export const QUICK_ACTIONS_SCROLL_COLLAPSE_THRESHOLD_PX = 24 export const QUICK_ACTIONS_SNAP_POINTS = [ QUICK_ACTIONS_SNAP_COLLAPSED, - QUICK_ACTIONS_SNAP_EXPANDED, + QUICK_ACTIONS_SNAP_DOCK, ] as const export type QuickActionsSnapPoint = (typeof QUICK_ACTIONS_SNAP_POINTS)[number] -export const quickActionsSnapIsExpanded = (snap: QuickActionsSnapPoint | null): boolean => - snap === QUICK_ACTIONS_SNAP_EXPANDED +export const quickActionsSnapIsDock = (snap: QuickActionsSnapPoint | null): boolean => + snap === QUICK_ACTIONS_SNAP_DOCK diff --git a/tests/e2e/campaignMunicipalities.e2e.spec.ts b/tests/e2e/campaignMunicipalities.e2e.spec.ts index fcc80a20..c71eb8a8 100644 --- a/tests/e2e/campaignMunicipalities.e2e.spec.ts +++ b/tests/e2e/campaignMunicipalities.e2e.spec.ts @@ -406,3 +406,44 @@ test.describe('Municípios — cards no celular (B42)', () => { await expect(page.getByRole('heading', { name: municipality.name })).toBeVisible() }) }) + +/** + * B100: bottom drawer loads docked with search visible, collapses on page scroll, + * and reopens only from the handle — not when scrolling back to the top. + */ +test.describe('Municípios — bottom drawer mobile (B100)', () => { + test.use({ viewport: { width: 390, height: 844 } }) + + test('dock on load, collapse on scroll down, reopen on handle tap', async ({ campaign, page }) => { + const { fixtures } = campaign + const coordinator = await fixtures.createCampaignUser('coordinator', { + name: fixtures.value('Coordenador Drawer'), + }) + const municipality = await fixtures.claimMunicipality() + + await campaign.login(page, coordinator.email!, coordinator.password) + await page.goto(`${campaign.baseURL}/campanha/municipios/${municipality.slug}`) + await expect(page.getByRole('heading', { name: municipality.name })).toBeVisible() + + const search = page.getByLabel('Buscar na campanha') + await expect(search).toBeInViewport() + + const scrollport = page.locator('[data-slot="campaign-content-scroll"]') + await scrollport.evaluate((el) => { + el.scrollTop = 80 + el.dispatchEvent(new Event('scroll')) + }) + + await expect(search).not.toBeInViewport() + await expect(page.getByRole('button', { name: 'Mostrar ações rápidas' })).toBeInViewport() + + await scrollport.evaluate((el) => { + el.scrollTop = 0 + el.dispatchEvent(new Event('scroll')) + }) + await expect(search).not.toBeInViewport() + + await page.getByRole('button', { name: 'Mostrar ações rápidas' }).click() + await expect(search).toBeInViewport() + }) +}) diff --git a/tests/unit/campaignQuickAction.unit.spec.ts b/tests/unit/campaignQuickAction.unit.spec.ts index d00212ef..c6c09fa6 100644 --- a/tests/unit/campaignQuickAction.unit.spec.ts +++ b/tests/unit/campaignQuickAction.unit.spec.ts @@ -38,8 +38,8 @@ import { import { resolveQuickActionsForPath } from '@/lib/campaignQuickActionRegistry' import { QUICK_ACTIONS_SNAP_COLLAPSED, - QUICK_ACTIONS_SNAP_EXPANDED, - quickActionsSnapIsExpanded, + QUICK_ACTIONS_SNAP_DOCK, + quickActionsSnapIsDock, } from '@/lib/campaignQuickActionSnap' import { resolveOrganizationQuickActions } from '@/lib/organizationQuickActions' @@ -395,9 +395,9 @@ describe('campaignQuickActionDemands resolvers', () => { }) 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) + it('detects dock snap', () => { + expect(quickActionsSnapIsDock(QUICK_ACTIONS_SNAP_DOCK)).toBe(true) + expect(quickActionsSnapIsDock(QUICK_ACTIONS_SNAP_COLLAPSED)).toBe(false) + expect(quickActionsSnapIsDock(null)).toBe(false) }) }) diff --git a/tests/unit/campaignQuickActionsDrawer.unit.spec.tsx b/tests/unit/campaignQuickActionsDrawer.unit.spec.tsx index 8481f257..f9aac455 100644 --- a/tests/unit/campaignQuickActionsDrawer.unit.spec.tsx +++ b/tests/unit/campaignQuickActionsDrawer.unit.spec.tsx @@ -1,13 +1,31 @@ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import type { ReactNode } from 'react' import { afterEach, describe, expect, it, vi } from 'vitest' +import { CampaignGlobalSearchProvider } from '@/components/campaign/dashboard/CampaignGlobalSearchMount' +import { CampaignContentScroll } from '@/components/campaign/shell/CampaignQuickActionsHost' import { CampaignQuickActionsDrawer } from '@/components/campaign/shell/CampaignQuickActionsDrawer' +import { + CampaignQuickActionsScrollCollapse, + CampaignQuickActionsSnapProvider, + useCampaignQuickActionsSnap, +} from '@/components/campaign/shell/CampaignQuickActionsSnapContext' import { postCampaignJson } from '@/lib/campaignJsonRequest' +import { + QUICK_ACTIONS_SCROLL_COLLAPSE_THRESHOLD_PX, + QUICK_ACTIONS_SNAP_COLLAPSED, + QUICK_ACTIONS_SNAP_DOCK, + quickActionsSnapIsDock, +} from '@/lib/campaignQuickActionSnap' vi.mock('@/lib/campaignJsonRequest', () => ({ postCampaignJson: vi.fn(), })) +vi.mock('next/navigation', () => ({ + usePathname: () => '/campanha/municipios/foo', +})) + const idleSuggestPayload = { status: 'success' as const, resultKind: 'suggest' as const, @@ -20,29 +38,41 @@ const idleSuggestPayload = { demands: [], } +const renderQuickActionsChrome = (ui: ReactNode) => + render( + + + + {ui} + + , + ) + +const SnapReadout = () => { + const { snapPoint } = useCampaignQuickActionsSnap() + return {snapPoint ?? 'null'} +} + afterEach(() => { cleanup() vi.mocked(postCampaignJson).mockReset() }) -describe('CampaignQuickActionsDrawer (B91)', () => { - it('shows global search in the expanded snap', () => { +describe('CampaignQuickActionsDrawer (B100)', () => { + it('loads in dock snap with global search visible', () => { vi.mocked(postCampaignJson).mockResolvedValue({ ok: true, payload: idleSuggestPayload, }) - render() - - expect(screen.queryByLabelText('Buscar na campanha')).toBeNull() - - fireEvent.click(screen.getByRole('button', { name: 'Mostrar ações rápidas' })) + renderQuickActionsChrome() const context = document.getElementById('quickActionContext') expect(context?.className).not.toContain('hidden') - expect(context?.getAttribute('data-snap')).toBe('expanded') + expect(context?.getAttribute('data-snap')).toBe('dock') expect(screen.getByLabelText('Buscar na campanha')).toBeTruthy() expect(screen.getByRole('region', { name: 'Resultados da busca' })).toBeTruthy() + expect(screen.getByRole('button', { name: 'Ocultar ações rápidas' })).toBeTruthy() }) it('posts home-search when the drawer query is active', async () => { @@ -51,9 +81,8 @@ describe('CampaignQuickActionsDrawer (B91)', () => { payload: idleSuggestPayload, }) - render() + renderQuickActionsChrome() - fireEvent.click(screen.getByRole('button', { name: 'Mostrar ações rápidas' })) fireEvent.change(screen.getByLabelText('Buscar na campanha'), { target: { value: 'cairu' } }) await waitFor(() => { @@ -64,4 +93,63 @@ describe('CampaignQuickActionsDrawer (B91)', () => { ) }) }) + + it('toggles between dock and collapsed from the handle', () => { + vi.mocked(postCampaignJson).mockResolvedValue({ + ok: true, + payload: idleSuggestPayload, + }) + + renderQuickActionsChrome() + + fireEvent.click(screen.getByRole('button', { name: 'Ocultar ações rápidas' })) + expect(document.getElementById('quickActionContext')?.getAttribute('data-snap')).toBe( + 'collapsed', + ) + expect(screen.getByRole('button', { name: 'Mostrar ações rápidas' })).toBeTruthy() + + fireEvent.click(screen.getByRole('button', { name: 'Mostrar ações rápidas' })) + expect(document.getElementById('quickActionContext')?.getAttribute('data-snap')).toBe('dock') + }) +}) + +describe('CampaignQuickActionsScrollCollapse (B100)', () => { + it('collapses on content scroll down but not when scrolling back to top', () => { + renderQuickActionsChrome( + <> + + + + + >, + ) + + 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, + }) + fireEvent.scroll(scrollport!) + + expect(screen.getByTestId('snap').textContent).toBe(QUICK_ACTIONS_SNAP_COLLAPSED) + + Object.defineProperty(scrollport, 'scrollTop', { + configurable: true, + value: 0, + }) + fireEvent.scroll(scrollport!) + + expect(screen.getByTestId('snap').textContent).toBe(QUICK_ACTIONS_SNAP_COLLAPSED) + }) +}) + +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) + }) })