B67: restore fine-pointer drag pan on home action strip

B58 hid the scrollbar but dropped mouse drag-to-scroll; pointer-fine
handlers pan past an 8px threshold while coarse touch keeps native pan-x.
Includes simplify pass (imperative data-dragging, coarse-pointer test)
and pin ratchet for municipalityElectoralBaseline bypass count.
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-29 21:57:53 -03:00
parent daf3229a08
commit 4cb59597ff
6 changed files with 256 additions and 32 deletions

View file

@ -8,7 +8,8 @@ alwaysApply: false
## Status
- **B56 entregue em código (2026-07-29):** resumo da campanha no Início staff — `loadCampaignHomeSummary` + `CampaignHomeSummary` (total central `staffVoteTotal` + cobertura E8 com `Progress`); `summarySlot` em `CampaignHomeLayout`/`CampaignHomeStaffChrome`, oculto no modo focado B47; sem saudação; leader fora. Loader em `campaignDashboardData.ts` (não módulo top-level novo). Unit `campaignHomeSummary.unit.spec.tsx`. Sem migration. Plano: [`resumo-campanha-inicio.md`](../../../docs/plans/resumo-campanha-inicio.md). Desbloqueia slot visual de **B57** (delta 7d, futuro).
- **B58 entregue em código (2026-07-29):** polimento visual da strip de ações do Início (UX-1) — rótulos curtos no catálogo (`Ajustar votos`, `Registrar sinal`, `Ver esquecidos`, …); removido o `h2` "O que você quer fazer?" (`aria-label` da strip = "Ações rápidas"); scrollbar horizontal oculta com pan preservado; escala ~1,05 no círculo (`pointer-fine` hover / `pointer-coarse` `data-pressing`) + `active:opacity-90`; e2e B47 retarget para `[data-slot="home-actions"]`. Sem migration/action/URL. Plano: [`polimento-strip-acoes-inicio.md`](../../../docs/plans/polimento-strip-acoes-inicio.md). Próximo chassis: **B48** resultados busca.
- **B67 entregue em código (2026-07-29):** pan na strip de ações do Início (UX-1) — `CampaignHomeActionStrip` com drag-to-scroll em `pointer-fine` (`HOME_ACTION_STRIP_DRAG_THRESHOLD_PX` 8, `cursor-grab`/`grabbing`); scrollbar oculta (B58) mantida; touch `pan-x` nativo; `onClickCapture` suprime link após arraste. Unit em `campaignHomeActionButton.unit.spec.tsx`. Sem migration. Plano: [`restaurar-pan-strip-acoes-inicio.md`](../../../docs/plans/restaurar-pan-strip-acoes-inicio.md). Fecha Adiado drag desktop de B44.
- **B58 entregue em código (2026-07-29):** polimento visual da strip de ações do Início (UX-1) — rótulos curtos no catálogo (`Ajustar votos`, `Registrar sinal`, `Ver esquecidos`, …); removido o `h2` "O que você quer fazer?" (`aria-label` da strip = "Ações rápidas"); scrollbar horizontal oculta (pan nativo touch; drag fine em **B67** ✓); escala ~1,05 no círculo (`pointer-fine` hover / `pointer-coarse` `data-pressing`) + `active:opacity-90`; e2e B47 retarget para `[data-slot="home-actions"]`. Sem migration/action/URL. Plano: [`polimento-strip-acoes-inicio.md`](../../../docs/plans/polimento-strip-acoes-inicio.md).
- **Fill-in Cenário junto aos filtros entregue em código (2026-07-29):** em `/campanha/municipios` (staff), o seletor **Cenário** (`VoteEstimateScenarioField`) saiu do header do `MunicipalityListOverview` e entrou na barra slim de `MunicipalityFilters` (desktop: antes de Salvar/Limpar; mobile: após filtros staff, antes de Ordenar). `MunicipalityEstimateScenarioProvider` passou a envolver filtros + resultados; cenário continua estado local (fora da URL e do **Limpar**). Hint `VOTE_ESTIMATE_SCENARIO_FILTERS_HINT`. Sem migration/action/Consent. Plano: [`cenario-junto-filtros-municipios.md`](../../../docs/plans/cenario-junto-filtros-municipios.md).
- **B48 entregue em código (2026-07-29):** primeiro provider da busca global do Início — grupo **Municípios** (municípios scoped + TIs no mesmo bloco) via `POST /campanha/home-search`, `searchHomeMunicipalities`, `HomeSearchMunicipalityGroup` no slot B47; readout 2022 alinhado à lista (`MunicipalityVotePositionReadout`). Próximo: **B49** lideranças (mesmo payload); **B54** grid/cap. Plano: [`busca-global-resultados-municipios.md`](../../../docs/plans/busca-global-resultados-municipios.md).
- **B47 entregue em código (2026-07-29):** busca global do Início (UX-1) — input staff-only com debounce 250 ms (`HOME_SEARCH_DEBOUNCE_MS`), sem botão Pesquisar; modo focado quando query debounced trim ≥2 chars esconde strip B45 e spacer thumb-zone (`CampaignHomeLayout` `focused`). Ilha `CampaignHomeStaffChrome` + `CampaignHomeSearch` + `HomeSearchContext` / `useHomeSearch` para grupos B48+; contrato em `src/lib/campaignHomeSearchContract.ts`. Leader continua sem omnibox (`CampaignHomeLayout` seco). Escape limpa. Sem migration. Plano: [`busca-global-inicio-input.md`](../../../docs/plans/busca-global-inicio-input.md). ~~Próximo: **B48** resultados Municípios.~~

View file

@ -1,7 +1,7 @@
# Restaurar pan/scroll na strip de ações (scrollbar oculta)
Status: rascunho
Atualizado em: 2026-07-29
Status: entregue (2026-07-29)
Atualizado em: 2026-07-29 — as-built: `pointer-fine` drag-to-scroll em `CampaignHomeActionStrip` (`HOME_ACTION_STRIP_DRAG_THRESHOLD_PX` 8); scrollbar oculta mantida; `onClickCapture` suprime navegação após arraste; touch `pan-x` nativo inalterado; fecha Adiado drag desktop de B44.
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B, item **B67** — UX-1 chassis)
Impeccable: B — encaixe em `CampaignHomeActionStrip` / `CampaignHomeActionButton`
Appetite: ~0,25–0,5 dia eng; overflow + drag-to-scroll; sem migration

File diff suppressed because one or more lines are too long

View file

@ -1,12 +1,34 @@
'use client'
import { useCallback, useRef, type MouseEvent, type PointerEvent } from 'react'
import {
CampaignHomeActionButton,
type CampaignHomeActionButtonProps,
} from '@/components/campaign/dashboard/CampaignHomeActionButton'
import { TooltipProvider } from '@/components/ui/tooltip'
import { useCoarsePointer } from '@/lib/campaignCoarsePointer'
import { cn } from '@/lib/utils'
/** Horizontal pan threshold (px) — below this, child link/button clicks still fire. */
export const HOME_ACTION_STRIP_DRAG_THRESHOLD_PX = 8
type DragSession = {
pointerId: number
startX: number
startScrollLeft: number
dragging: boolean
}
const setScrollerDragging = (el: HTMLDivElement | null, dragging: boolean) => {
if (!el) return
if (dragging) {
el.dataset.dragging = 'true'
} else {
delete el.dataset.dragging
}
}
export const CampaignHomeActionStrip = ({
actions,
ariaLabel = 'Ações rápidas',
@ -15,22 +37,116 @@ export const CampaignHomeActionStrip = ({
actions?: readonly (CampaignHomeActionButtonProps & { id?: string })[]
ariaLabel?: string
className?: string
}) => (
<TooltipProvider delayDuration={300}>
<div
aria-label={ariaLabel}
className={cn(
'min-w-0 overflow-x-auto overflow-y-hidden overscroll-x-contain [touch-action:pan-x] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden',
className,
)}
>
<ul role="list" className="m-0 flex min-w-max list-none snap-x snap-proximity gap-6 p-0 pb-1">
{actions?.map(({ id, ...button }) => (
<li key={id ?? button.href ?? button.label} className="m-0 list-none p-0">
<CampaignHomeActionButton {...button} />
</li>
))}
</ul>
</div>
</TooltipProvider>
)
}) => {
const isCoarsePointer = useCoarsePointer()
const scrollerRef = useRef<HTMLDivElement>(null)
const dragRef = useRef<DragSession | null>(null)
const suppressClickRef = useRef(false)
const endDragSession = useCallback((pointerId: number) => {
const session = dragRef.current
if (!session || session.pointerId !== pointerId) return
const el = scrollerRef.current
if (el && typeof el.hasPointerCapture === 'function' && el.hasPointerCapture(pointerId)) {
el.releasePointerCapture(pointerId)
}
if (session.dragging) {
suppressClickRef.current = true
}
setScrollerDragging(el, false)
dragRef.current = null
}, [])
const onPointerDown = useCallback(
(event: PointerEvent<HTMLDivElement>) => {
if (isCoarsePointer || event.button !== 0) return
const el = scrollerRef.current
if (!el) return
dragRef.current = {
pointerId: event.pointerId,
startX: event.clientX,
startScrollLeft: el.scrollLeft,
dragging: false,
}
suppressClickRef.current = false
if (typeof el.setPointerCapture === 'function') {
el.setPointerCapture(event.pointerId)
}
},
[isCoarsePointer],
)
const onPointerMove = useCallback((event: PointerEvent<HTMLDivElement>) => {
const session = dragRef.current
if (!session || session.pointerId !== event.pointerId) return
const el = scrollerRef.current
if (!el) return
const deltaX = event.clientX - session.startX
if (!session.dragging) {
if (Math.abs(deltaX) < HOME_ACTION_STRIP_DRAG_THRESHOLD_PX) return
session.dragging = true
setScrollerDragging(el, true)
}
el.scrollLeft = session.startScrollLeft - deltaX
event.preventDefault()
}, [])
const onPointerEnd = useCallback(
(event: PointerEvent<HTMLDivElement>) => {
endDragSession(event.pointerId)
},
[endDragSession],
)
const onClickCapture = useCallback((event: MouseEvent<HTMLDivElement>) => {
if (!suppressClickRef.current) return
event.preventDefault()
event.stopPropagation()
suppressClickRef.current = false
}, [])
const finePointerPanProps = isCoarsePointer
? {}
: {
onPointerDown,
onPointerMove,
onPointerUp: onPointerEnd,
onPointerCancel: onPointerEnd,
onClickCapture,
}
return (
<TooltipProvider delayDuration={300}>
<div
ref={scrollerRef}
aria-label={ariaLabel}
className={cn(
'min-w-0 overflow-x-auto overflow-y-hidden overscroll-x-contain [touch-action:pan-x] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden',
'pointer-fine:cursor-grab pointer-fine:data-[dragging=true]:cursor-grabbing pointer-fine:data-[dragging=true]:select-none',
className,
)}
{...finePointerPanProps}
>
<ul
role="list"
className="m-0 flex min-w-max list-none snap-x snap-proximity gap-6 p-0 pb-1"
>
{actions?.map(({ id, ...button }) => (
<li key={id ?? button.href ?? button.label} className="m-0 list-none p-0">
<CampaignHomeActionButton {...button} />
</li>
))}
</ul>
</div>
</TooltipProvider>
)
}

View file

@ -6,7 +6,10 @@ import {
CampaignHomeActionButton,
type CampaignHomeActionButtonProps,
} from '@/components/campaign/dashboard/CampaignHomeActionButton'
import { CampaignHomeActionStrip } from '@/components/campaign/dashboard/CampaignHomeActionStrip'
import {
CampaignHomeActionStrip,
HOME_ACTION_STRIP_DRAG_THRESHOLD_PX,
} from '@/components/campaign/dashboard/CampaignHomeActionStrip'
import { TooltipProvider } from '@/components/ui/tooltip'
import { resetCampaignCoarsePointerForTests } from '@/lib/campaignCoarsePointer'
import { CAMPAIGN_LONG_PRESS_MS } from '@/lib/campaignLongPress'
@ -150,6 +153,20 @@ describe('CampaignHomeActionButton', () => {
})
describe('CampaignHomeActionStrip', () => {
const manyActions = Array.from({ length: 8 }, (_, index) => ({
label: `Ação ${index + 1}`,
icon: BarChart3,
}))
const getScroller = (container: HTMLElement) =>
container.querySelector('[aria-label="Ações rápidas"]') as HTMLDivElement
const mockScrollerOverflow = (scroller: HTMLDivElement) => {
Object.defineProperty(scroller, 'scrollWidth', { value: 800, configurable: true })
Object.defineProperty(scroller, 'clientWidth', { value: 200, configurable: true })
scroller.scrollLeft = 0
}
it('renders a horizontal scroller with list semantics', () => {
const { container } = render(
<CampaignHomeActionStrip
@ -160,9 +177,99 @@ describe('CampaignHomeActionStrip', () => {
/>,
)
const scroller = container.querySelector('[aria-label="Ações rápidas"]')
const scroller = getScroller(container)
expect(scroller?.className).toContain('overflow-x-auto')
expect(scroller?.className).toContain('scrollbar-width:none')
expect(scroller?.querySelector('ul[role="list"]')).toBeTruthy()
})
it('scrolls horizontally on pointer-fine drag past the threshold', () => {
const { container } = render(<CampaignHomeActionStrip actions={manyActions} />)
const scroller = getScroller(container)
mockScrollerOverflow(scroller)
const startX = 120
const dragDelta = HOME_ACTION_STRIP_DRAG_THRESHOLD_PX + 20
fireEvent.pointerDown(scroller, { button: 0, clientX: startX, pointerId: 1 })
fireEvent.pointerMove(scroller, {
button: 0,
clientX: startX - dragDelta,
pointerId: 1,
})
expect(scroller.scrollLeft).toBe(dragDelta)
expect(scroller.dataset.dragging).toBe('true')
fireEvent.pointerUp(scroller, { pointerId: 1 })
expect(scroller.dataset.dragging).toBeUndefined()
})
it('does not pan on coarse pointer — touch uses native overflow scroll', () => {
resetCampaignCoarsePointerForTests()
matchMediaMock.mockImplementation((query: string) => ({
matches: query.includes('coarse'),
media: query,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
}))
const onClick = vi.fn()
const { container } = render(
<CampaignHomeActionStrip
actions={[{ label: 'Registrar', icon: BarChart3, onClick }, ...manyActions]}
/>,
)
const scroller = getScroller(container)
mockScrollerOverflow(scroller)
fireEvent.pointerDown(scroller, { button: 0, clientX: 200, pointerId: 4 })
fireEvent.pointerMove(scroller, { button: 0, clientX: 50, pointerId: 4 })
fireEvent.pointerUp(scroller, { pointerId: 4 })
expect(scroller.scrollLeft).toBe(0)
fireEvent.click(screen.getByRole('button', { name: 'Registrar' }))
expect(onClick).toHaveBeenCalledTimes(1)
})
it('suppresses child click after a drag gesture', () => {
const onClick = vi.fn()
const { container } = render(
<CampaignHomeActionStrip
actions={[{ label: 'Registrar', icon: BarChart3, onClick }, ...manyActions.slice(0, 3)]}
/>,
)
const scroller = getScroller(container)
mockScrollerOverflow(scroller)
fireEvent.pointerDown(scroller, { button: 0, clientX: 200, pointerId: 2 })
fireEvent.pointerMove(scroller, { button: 0, clientX: 100, pointerId: 2 })
fireEvent.pointerUp(scroller, { pointerId: 2 })
fireEvent.click(screen.getByRole('button', { name: 'Registrar' }))
expect(onClick).not.toHaveBeenCalled()
})
it('allows child click when movement stays below the drag threshold', () => {
const onClick = vi.fn()
const { container } = render(
<CampaignHomeActionStrip actions={[{ label: 'Registrar', icon: BarChart3, onClick }]} />,
)
const scroller = getScroller(container)
mockScrollerOverflow(scroller)
const startX = 80
fireEvent.pointerDown(scroller, { button: 0, clientX: startX, pointerId: 3 })
fireEvent.pointerMove(scroller, {
button: 0,
clientX: startX - HOME_ACTION_STRIP_DRAG_THRESHOLD_PX + 2,
pointerId: 3,
})
fireEvent.pointerUp(scroller, { pointerId: 3 })
fireEvent.click(screen.getByRole('button', { name: 'Registrar' }))
expect(onClick).toHaveBeenCalledTimes(1)
})
})

View file

@ -565,7 +565,7 @@ describe('admin bypasses carry a justification comment', () => {
['src/utilities/electionCandidateOptions.ts', 1],
['src/utilities/territory/loadTerritoryOverview.ts', 1],
['src/utilities/municipality/municipalityCandidateComparison.ts', 2],
['src/utilities/municipality/municipalityElectoralBaseline.ts', 9],
['src/utilities/municipality/municipalityElectoralBaseline.ts', 8],
['src/utilities/onda0Provision.ts', 4],
['src/utilities/stateDeputyData.ts', 1],
['src/utilities/visit/visitPlannerData.ts', 1],