Merge pull request #139 from fsolla/cursor/pool-i132-dfca371b-fa84

B105 — Bottom drawer — handle, gesto, scroll, peek discreto, labels 2 linhas
This commit is contained in:
Francisco Solla 2026-08-01 15:55:18 -03:00 committed by GitHub
commit 7e99bce9a5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 199 additions and 59 deletions

View file

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

View file

@ -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 = ({
<CampaignSearchInput
id={HOME_SEARCH_INPUT_ID}
label={HOME_SEARCH_LABEL}
placeholder="Município, liderança, atividade…"
placeholder={placeholder}
value={query.raw}
onChange={(event) => 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}
</div>

View file

@ -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 (
<CampaignHomeSearch resultsBusy={searchResultsState.isFetching}>
<CampaignHomeSearch
resultsBusy={searchResultsState.isFetching}
placeholder={placeholder}
showResults={showResults}
>
<HomeSearchResultsProvider value={searchResultsState}>
<HomeSearchResultsShell>
<HomeSearchResultsLayout>
@ -34,6 +45,3 @@ const CampaignStaffGlobalSearchResults = () => {
</CampaignHomeSearch>
)
}
/** Result groups + input — requires an ancestor `HomeSearchProvider`. */
export const CampaignStaffGlobalSearchBody = CampaignStaffGlobalSearchResults

View file

@ -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 (
<Drawer
open
@ -68,17 +77,26 @@ export const CampaignQuickActionsDrawer = ({
className="border-t border-border bg-background text-foreground shadow-[0_-4px_24px_-8px_rgb(0_0_0/0.12)] print:hidden"
>
<DrawerTitle className="sr-only">Ações rápidas</DrawerTitle>
<button
type="button"
onClick={toggleSnap}
aria-expanded={isDock}
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={isDock ? '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 gap-4 overflow-hidden px-4 pt-2',
isDock ? 'flex-1' : 'hidden',
'flex min-h-0 flex-col gap-3 overflow-y-auto px-4 pb-[max(0.5rem,env(safe-area-inset-bottom,0px))]',
isDock ? 'flex-1' : 'shrink-0',
)}
data-snap={isDock ? 'dock' : 'collapsed'}
aria-hidden={!isDock}
>
{showActions ? (
<div className="-mx-4 w-[calc(100%+2rem)]">
<div hidden={!isDock} className="-mx-4 w-[calc(100%+2rem)]">
<CampaignHomeActionStrip
actions={actions.map((action) => ({
id: action.id,
@ -91,18 +109,8 @@ export const CampaignQuickActionsDrawer = ({
/>
</div>
) : null}
<CampaignGlobalSearchBody />
<CampaignGlobalSearchBody placeholder={isDock ? undefined : ''} showResults={isDock} />
</div>
<button
type="button"
onClick={toggleSnap}
aria-expanded={isDock}
aria-controls="quickActionContext"
className="flex w-full shrink-0 cursor-grab flex-col items-center border-0 bg-transparent px-4 pt-2 pb-[max(0.25rem,env(safe-area-inset-bottom,0px))] active:cursor-grabbing"
aria-label={isDock ? '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>
</DrawerContent>
</Drawer>
)

View file

@ -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<HTMLDivElement>(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 (
<div

View file

@ -1,10 +1,13 @@
/** Collapsed peek — swipe handle only (replaces legacy bottom-nav `pb-24`). */
export const QUICK_ACTIONS_SNAP_COLLAPSED = '3rem' as const
/** Collapsed peek — handle + discreet search (no action strip). */
export const QUICK_ACTIONS_SNAP_COLLAPSED = '6.5rem' as const
/** Dock — action strip + global search visible on load and after navigation. */
export const QUICK_ACTIONS_SNAP_DOCK = '12rem' as const
/** Dock — handle + action strip (2-line labels) + global search. */
export const QUICK_ACTIONS_SNAP_DOCK = '15rem' as const
/** Scroll down past this threshold on `campaign-content-scroll` collapses the drawer. */
/**
* 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 = [
@ -14,5 +17,19 @@ export const QUICK_ACTIONS_SNAP_POINTS = [
export type QuickActionsSnapPoint = (typeof QUICK_ACTIONS_SNAP_POINTS)[number]
export type QuickActionsScrollDirection = 'up' | 'down' | 'none'
export const quickActionsSnapIsDock = (snap: QuickActionsSnapPoint | null): boolean =>
snap === QUICK_ACTIONS_SNAP_DOCK
/** Pure scroll-direction detector for the quick-actions peek (B105). */
export const quickActionsScrollDirection = (
previousScrollTop: number,
nextScrollTop: number,
thresholdPx: number = QUICK_ACTIONS_SCROLL_COLLAPSE_THRESHOLD_PX,
): QuickActionsScrollDirection => {
const delta = nextScrollTop - previousScrollTop
if (delta > thresholdPx) return 'down'
if (delta < -thresholdPx) return 'up'
return 'none'
}

View file

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

View file

@ -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(
<HomeSearchProvider value={providerValue}>
<CampaignHomeSearch placeholder="" showResults={false}>
<p>Group stub</p>
</CampaignHomeSearch>
</HomeSearchProvider>,
)
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)
})
})

View file

@ -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 <div data-testid="snap">{snapPoint ?? 'null'}</div>
}
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(<CampaignQuickActionsDrawer actions={[]} />)
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(<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)', () => {
@ -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(<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('dock')
})
it('stays docked while search remains focused even if the handle collapses', () => {
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('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(
<>
<CampaignContentScroll quickActionsPeek>
@ -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')
})
})