diff --git a/docs/plans/bottom-drawer-handle-scroll-peek.md b/docs/plans/bottom-drawer-handle-scroll-peek.md index e94ee97d..341e7eaf 100644 --- a/docs/plans/bottom-drawer-handle-scroll-peek.md +++ b/docs/plans/bottom-drawer-handle-scroll-peek.md @@ -1,6 +1,6 @@ # Bottom drawer — handle, gesto, scroll, peek discreto, labels 2 linhas -Status: registrado +Status: implementado (PR) Atualizado em: 2026-08-01 Issue: #132 Priority: P1 @@ -82,8 +82,8 @@ Dados: N/A — chrome; hits = B48+ via provider existente. ## Questões em aberto -- **Limiar scroll-up para abrir?** **Opções:** A) espelhar 24 px de delta ↑ | B) `scrollTop < 24` (só perto do topo). **Recomendação:** **A** (simétrico ao collapse). _(assumido)_ -- **Focus no input collapsed:** expande para dock automaticamente? **Opções:** A) sim | B) só digitar. **Recomendação:** **A**. _(assumido)_ +- **Limiar scroll-up para abrir?** **Opções:** A) espelhar 24 px de delta ↑ | B) `scrollTop < 24` (só perto do topo). **Recomendação:** **A** (simétrico ao collapse). ✅ travado em craft (`quickActionsScrollDirection`). +- **Focus no input collapsed:** expande para dock automaticamente? **Opções:** A) sim | B) só digitar. **Recomendação:** **A**. ✅ travado em craft (`uiFocused` → dock). ## Abordagem proposta diff --git a/src/components/campaign/dashboard/CampaignHomeSearch.tsx b/src/components/campaign/dashboard/CampaignHomeSearch.tsx index a4920075..002c2eb7 100644 --- a/src/components/campaign/dashboard/CampaignHomeSearch.tsx +++ b/src/components/campaign/dashboard/CampaignHomeSearch.tsx @@ -8,14 +8,21 @@ import { HOME_SEARCH_QUERY_MAX_LENGTH } from '@/lib/schemas/homeSearch' const HOME_SEARCH_INPUT_ID = 'homeSearchQuery' const HOME_SEARCH_LABEL = 'Buscar na campanha' +const HOME_SEARCH_PLACEHOLDER = 'Município, liderança, atividade…' export const CampaignHomeSearch = ({ children, resultsBusy, + placeholder = HOME_SEARCH_PLACEHOLDER, + showResults = true, }: { children?: ReactNode /** When set, must already fold debounce (see `useHomeSearchResultsState.isFetching`). */ resultsBusy?: boolean + /** Empty string = discreet peek (collapsed drawer); sr-only label stays. */ + placeholder?: string + /** When false, keeps the live region mounted but visually hidden (collapsed peek). */ + showResults?: boolean }) => { const { query, setRaw, clear, isDebouncing, setInputFocused } = useHomeSearch() const ariaBusy = (resultsBusy ?? isDebouncing) || undefined @@ -25,7 +32,7 @@ export const CampaignHomeSearch = ({ setRaw(event.target.value)} onFocus={() => setInputFocused(true)} @@ -52,6 +59,7 @@ export const CampaignHomeSearch = ({ aria-busy={ariaBusy} className="min-w-0" data-slot="home-search-results" + hidden={!showResults} > {children} diff --git a/src/components/campaign/dashboard/CampaignStaffGlobalSearch.tsx b/src/components/campaign/dashboard/CampaignStaffGlobalSearch.tsx index 0fefbd0d..0d7fa918 100644 --- a/src/components/campaign/dashboard/CampaignStaffGlobalSearch.tsx +++ b/src/components/campaign/dashboard/CampaignStaffGlobalSearch.tsx @@ -14,11 +14,22 @@ import { HomeSearchResultsLayout } from '@/components/campaign/dashboard/HomeSea import { HomeSearchResultsShell } from '@/components/campaign/dashboard/HomeSearchResultsShell' import { HomeSearchStateDeputyGroup } from '@/components/campaign/dashboard/HomeSearchStateDeputyGroup' -const CampaignStaffGlobalSearchResults = () => { +export const CampaignStaffGlobalSearchBody = ({ + placeholder, + showResults = true, +}: { + /** Forwarded to `CampaignHomeSearch` — empty string for collapsed discreet peek. */ + placeholder?: string + showResults?: boolean +}) => { const searchResultsState = useHomeSearchResultsState() return ( - + @@ -34,6 +45,3 @@ const CampaignStaffGlobalSearchResults = () => { ) } - -/** Result groups + input — requires an ancestor `HomeSearchProvider`. */ -export const CampaignStaffGlobalSearchBody = CampaignStaffGlobalSearchResults diff --git a/src/components/campaign/shell/CampaignQuickActionsDrawer.tsx b/src/components/campaign/shell/CampaignQuickActionsDrawer.tsx index fa34ebe3..cc6ec09f 100644 --- a/src/components/campaign/shell/CampaignQuickActionsDrawer.tsx +++ b/src/components/campaign/shell/CampaignQuickActionsDrawer.tsx @@ -1,9 +1,10 @@ 'use client' -import { useCallback } from 'react' +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 { useCampaignQuickActionsSnap } from '@/components/campaign/shell/CampaignQuickActionsSnapContext' import { Drawer, DrawerContent, DrawerTitle } from '@/components/ui/Drawer' import { @@ -24,6 +25,7 @@ export const CampaignQuickActionsDrawer = ({ actions: readonly CampaignQuickAction[] }) => { const { snapPoint, setSnapPoint, isDock } = useCampaignQuickActionsSnap() + const { uiFocused } = useHomeSearch() const showActions = actions.length > 0 const handleSnapPointChange = useCallback( @@ -52,6 +54,13 @@ export const CampaignQuickActionsDrawer = ({ [setSnapPoint], ) + // Active search focus keeps the drawer docked (B105) — same gate as scroll. + useEffect(() => { + if (uiFocused && !isDock) { + setSnapPoint(QUICK_ACTIONS_SNAP_DOCK) + } + }, [isDock, setSnapPoint, uiFocused]) + return ( Ações rápidas +
{showActions ? ( -
+ ) : null} - +
- ) diff --git a/src/components/campaign/shell/CampaignQuickActionsHost.tsx b/src/components/campaign/shell/CampaignQuickActionsHost.tsx index ad7a6355..4ef05d99 100644 --- a/src/components/campaign/shell/CampaignQuickActionsHost.tsx +++ b/src/components/campaign/shell/CampaignQuickActionsHost.tsx @@ -11,27 +11,37 @@ import { useIsMobile } from '@/hooks/use-mobile' import { shouldMountQuickActionsDrawer } from '@/lib/campaignQuickActionMount' import { resolveQuickActionsForPath } from '@/lib/campaignQuickActionRegistry' import { - QUICK_ACTIONS_SCROLL_COLLAPSE_THRESHOLD_PX, QUICK_ACTIONS_SNAP_COLLAPSED, QUICK_ACTIONS_SNAP_DOCK, + quickActionsScrollDirection, } from '@/lib/campaignQuickActionSnap' import type { CampaignRole } from '@/lib/campaignRoles' import { cn } from '@/lib/utils' const CampaignContentScrollWithPeek = ({ children }: { children: ReactNode }) => { const scrollRef = useRef(null) + const lastScrollTopRef = useRef(0) const { isDock, setSnapPoint } = useCampaignQuickActionsSnap() const { uiFocused } = useHomeSearch() const peekHeight = isDock ? QUICK_ACTIONS_SNAP_DOCK : QUICK_ACTIONS_SNAP_COLLAPSED - const collapseIfScrolled = useCallback(() => { - if (uiFocused) return - + const syncSnapFromScroll = useCallback(() => { const scrollport = scrollRef.current if (!scrollport) return - if (scrollport.scrollTop > QUICK_ACTIONS_SCROLL_COLLAPSE_THRESHOLD_PX) { + 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]) @@ -39,11 +49,12 @@ const CampaignContentScrollWithPeek = ({ children }: { children: ReactNode }) => const scrollport = scrollRef.current if (!scrollport) return - scrollport.addEventListener('scroll', collapseIfScrolled, { passive: true }) + lastScrollTopRef.current = scrollport.scrollTop + scrollport.addEventListener('scroll', syncSnapFromScroll, { passive: true }) return () => { - scrollport.removeEventListener('scroll', collapseIfScrolled) + scrollport.removeEventListener('scroll', syncSnapFromScroll) } - }, [collapseIfScrolled]) + }, [syncSnapFromScroll]) return (
snap === QUICK_ACTIONS_SNAP_DOCK + +/** Pure scroll-direction detector for the quick-actions peek (B105). */ +export const quickActionsScrollDirection = ( + previousScrollTop: number, + nextScrollTop: number, + thresholdPx: number = QUICK_ACTIONS_SCROLL_COLLAPSE_THRESHOLD_PX, +): QuickActionsScrollDirection => { + const delta = nextScrollTop - previousScrollTop + if (delta > thresholdPx) return 'down' + if (delta < -thresholdPx) return 'up' + return 'none' +} diff --git a/tests/e2e/campaignMunicipalities.e2e.spec.ts b/tests/e2e/campaignMunicipalities.e2e.spec.ts index b178a075..1429411e 100644 --- a/tests/e2e/campaignMunicipalities.e2e.spec.ts +++ b/tests/e2e/campaignMunicipalities.e2e.spec.ts @@ -408,13 +408,13 @@ test.describe('Municípios — cards no celular (B42)', () => { }) /** - * 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. + * B105: bottom drawer loads docked with search visible, collapses on page scroll down + * (discreet search peek remains), and re-docks on scroll up; handle sits above the search. */ -test.describe('Municípios — bottom drawer mobile (B100)', () => { +test.describe('Municípios — bottom drawer mobile (B105)', () => { test.use({ viewport: { width: 390, height: 844 } }) - test('dock on load, collapse on scroll down, reopen on handle tap', async ({ + test('dock on load, collapse on scroll down with search peek, reopen on scroll up', async ({ campaign, page, }) => { @@ -430,6 +430,14 @@ test.describe('Municípios — bottom drawer mobile (B100)', () => { const search = page.getByLabel('Buscar na campanha') await expect(search).toBeInViewport() + await expect(search).toHaveAttribute('placeholder', 'Município, liderança, atividade…') + + const handle = page.getByRole('button', { name: 'Ocultar ações rápidas' }) + const handleBox = await handle.boundingBox() + const searchBox = await search.boundingBox() + expect(handleBox).toBeTruthy() + expect(searchBox).toBeTruthy() + expect(handleBox!.y).toBeLessThan(searchBox!.y) const scrollport = page.locator('[data-slot="campaign-content-scroll"]') await scrollport.evaluate((el) => { @@ -438,15 +446,15 @@ test.describe('Municípios — bottom drawer mobile (B100)', () => { }) await expect(page.getByRole('button', { name: 'Mostrar ações rápidas' })).toBeVisible() - await expect(search).not.toBeInViewport() + await expect(search).toBeInViewport() + await expect(search).toHaveAttribute('placeholder', '') 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(page.getByRole('button', { name: 'Ocultar ações rápidas' })).toBeVisible() await expect(search).toBeInViewport() + await expect(search).toHaveAttribute('placeholder', 'Município, liderança, atividade…') }) }) diff --git a/tests/unit/campaignHomeSearch.unit.spec.tsx b/tests/unit/campaignHomeSearch.unit.spec.tsx index 1f40f99f..18554c19 100644 --- a/tests/unit/campaignHomeSearch.unit.spec.tsx +++ b/tests/unit/campaignHomeSearch.unit.spec.tsx @@ -1,5 +1,5 @@ -import { render, screen } from '@testing-library/react' -import { describe, expect, it, vi } from 'vitest' +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' import { CampaignHomeSearch } from '@/components/campaign/dashboard/CampaignHomeSearch' import { HomeSearchProvider } from '@/components/campaign/dashboard/HomeSearchContext' @@ -14,6 +14,10 @@ const providerValue = { uiFocused: true, } +afterEach(() => { + cleanup() +}) + describe('CampaignHomeSearch', () => { it('renders search input and results region', () => { render( @@ -25,7 +29,25 @@ describe('CampaignHomeSearch', () => { ) expect(screen.getByLabelText('Buscar na campanha')).toBeTruthy() + expect((screen.getByLabelText('Buscar na campanha') as HTMLInputElement).placeholder).toBe( + 'Município, liderança, atividade…', + ) expect(screen.getByRole('region', { name: 'Resultados da busca' })).toBeTruthy() expect(screen.getByText('Group stub')).toBeTruthy() }) + + it('supports discreet peek without placeholder and hidden results', () => { + render( + + +

Group stub

+
+
, + ) + + expect((screen.getByLabelText('Buscar na campanha') as HTMLInputElement).placeholder).toBe('') + const results = document.querySelector('[data-slot="home-search-results"]') + expect(results).toBeTruthy() + expect((results as HTMLElement).hidden).toBe(true) + }) }) diff --git a/tests/unit/campaignQuickActionsDrawer.unit.spec.tsx b/tests/unit/campaignQuickActionsDrawer.unit.spec.tsx index 1b0a876b..9611cc3c 100644 --- a/tests/unit/campaignQuickActionsDrawer.unit.spec.tsx +++ b/tests/unit/campaignQuickActionsDrawer.unit.spec.tsx @@ -17,6 +17,7 @@ import { QUICK_ACTIONS_SCROLL_COLLAPSE_THRESHOLD_PX, QUICK_ACTIONS_SNAP_COLLAPSED, QUICK_ACTIONS_SNAP_DOCK, + quickActionsScrollDirection, quickActionsSnapIsDock, } from '@/lib/campaignQuickActionSnap' @@ -99,8 +100,8 @@ const SnapReadout = () => { return
{snapPoint ?? 'null'}
} -describe('CampaignQuickActionsDrawer (B100)', () => { - it('loads in dock snap with global search visible', () => { +describe('CampaignQuickActionsDrawer (B105)', () => { + it('loads in dock snap with handle above search and placeholder visible', () => { vi.mocked(postCampaignJson).mockResolvedValue({ ok: true, payload: idleSuggestPayload, @@ -109,11 +110,35 @@ describe('CampaignQuickActionsDrawer (B100)', () => { renderQuickActionsChrome() const context = document.getElementById('quickActionContext') - expect(context?.className.split(/\s+/)).not.toContain('hidden') expect(context?.getAttribute('data-snap')).toBe('dock') - expect(screen.getByLabelText('Buscar na campanha')).toBeTruthy() + 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() + expect(search.getAttribute('placeholder')).toBe('Município, liderança, atividade…') expect(screen.getByRole('region', { name: 'Resultados da busca' })).toBeTruthy() - expect(screen.getByRole('button', { name: 'Ocultar ações rápidas' })).toBeTruthy() + }) + + it('collapsed peek keeps discreet search without placeholder; actions stay hidden', () => { + vi.mocked(postCampaignJson).mockResolvedValue({ + ok: true, + payload: idleSuggestPayload, + }) + + renderQuickActionsChrome() + + fireEvent.click(screen.getByRole('button', { name: 'Ocultar ações rápidas' })) + expect(document.getElementById('quickActionContext')?.getAttribute('data-snap')).toBe( + 'collapsed', + ) + + const search = screen.getByLabelText('Buscar na campanha') + expect(search.getAttribute('placeholder')).toBe('') + const results = document.querySelector('[data-slot="home-search-results"]') + expect(results).toBeTruthy() + expect((results as HTMLElement).hidden).toBe(true) + expect(screen.getByRole('button', { name: 'Mostrar ações rápidas' })).toBeTruthy() }) it('bleeds the action strip edge-to-edge inside the drawer gutter (B101)', () => { @@ -216,6 +241,36 @@ describe('CampaignQuickActionsDrawer (B100)', () => { }) }) + it('expands to dock when the peek search is focused', () => { + vi.mocked(postCampaignJson).mockResolvedValue({ + ok: true, + payload: idleSuggestPayload, + }) + + renderQuickActionsChrome() + + fireEvent.click(screen.getByRole('button', { name: 'Ocultar ações rápidas' })) + expect(document.getElementById('quickActionContext')?.getAttribute('data-snap')).toBe( + 'collapsed', + ) + + fireEvent.focus(screen.getByLabelText('Buscar na campanha')) + expect(document.getElementById('quickActionContext')?.getAttribute('data-snap')).toBe('dock') + }) + + it('stays docked while search remains focused even if the handle collapses', () => { + vi.mocked(postCampaignJson).mockResolvedValue({ + ok: true, + payload: idleSuggestPayload, + }) + + renderQuickActionsChrome() + + fireEvent.focus(screen.getByLabelText('Buscar na campanha')) + fireEvent.click(screen.getByRole('button', { name: 'Ocultar ações rápidas' })) + expect(document.getElementById('quickActionContext')?.getAttribute('data-snap')).toBe('dock') + }) + it('toggles between dock and collapsed from the handle', () => { vi.mocked(postCampaignJson).mockResolvedValue({ ok: true, @@ -228,7 +283,6 @@ describe('CampaignQuickActionsDrawer (B100)', () => { expect(document.getElementById('quickActionContext')?.getAttribute('data-snap')).toBe( 'collapsed', ) - expect(document.getElementById('quickActionContext')?.className).toContain('hidden') expect(screen.getByRole('button', { name: 'Mostrar ações rápidas' })).toBeTruthy() fireEvent.click(screen.getByRole('button', { name: 'Mostrar ações rápidas' })) @@ -236,8 +290,8 @@ describe('CampaignQuickActionsDrawer (B100)', () => { }) }) -describe('CampaignContentScroll quick-actions collapse (B100)', () => { - it('collapses on content scroll down but not when scrolling back to top', () => { +describe('CampaignContentScroll quick-actions direction (B105)', () => { + it('collapses on scroll down and re-docks on scroll up', () => { renderQuickActionsChrome( <> @@ -256,7 +310,6 @@ describe('CampaignContentScroll quick-actions collapse (B100)', () => { 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', { @@ -264,8 +317,7 @@ describe('CampaignContentScroll quick-actions collapse (B100)', () => { value: 0, }) fireEvent.scroll(scrollport!) - - expect(screen.getByTestId('snap').textContent).toBe(QUICK_ACTIONS_SNAP_COLLAPSED) + expect(screen.getByTestId('snap').textContent).toBe(QUICK_ACTIONS_SNAP_DOCK) }) }) @@ -275,4 +327,10 @@ describe('campaignQuickActionSnap', () => { expect(quickActionsSnapIsDock(QUICK_ACTIONS_SNAP_COLLAPSED)).toBe(false) expect(quickActionsSnapIsDock(null)).toBe(false) }) + + 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') + }) })