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:
parent
daf3229a08
commit
4cb59597ff
6 changed files with 256 additions and 32 deletions
|
|
@ -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.~~
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
Loading…
Reference in a new issue