diff --git a/.cursor/rules/projects/nucleos-eleitorais.mdc b/.cursor/rules/projects/nucleos-eleitorais.mdc
index 1183ddc0..861eba8e 100644
--- a/.cursor/rules/projects/nucleos-eleitorais.mdc
+++ b/.cursor/rules/projects/nucleos-eleitorais.mdc
@@ -7,6 +7,7 @@ alwaysApply: false
## Status
+- **B44 entregue em código (2026-07-29):** primitivo UX-1 — `CampaignHomeActionButton` (círculo `bg-muted` + ícone + label; um só controle focável) e `CampaignHomeActionStrip` (`overflow-x-auto`, `touch-pan-x`, snap). Descrição: Tooltip shadcn em ponteiro fino; long-press (~450ms) abre Drawer em ponteiro grosso (`useSyncExternalStore` + `matchMedia`, um alvo no DOM) sem disparar click (`src/lib/campaignLongPress.ts`). **Sem** montar catálogo em `/campanha` — B45. Specs: `campaignLongPress.unit.spec.tsx`, `campaignHomeActionButton.unit.spec.tsx`. Plano: [`botao-acao-inicio-strip.md`](../../../docs/plans/botao-acao-inicio-strip.md). Desbloqueia **B45**.
- **B43 entregue em código (2026-07-29):** chassis UX-1 — `/campanha` fica só shell (`CampaignPageShell` vazio); dashboard staff move para `/campanha/quadro` (corpo idêntico ao antigo Início); `LeaderContactsPanel` em `/campanha/contatos`; nav staff ganha **Quadro** (sidebar; fora do bottom nav mobile); liderança **Início** + **Meus contatos**; `requireCampaignPageActor` redireciona leader bloqueado para `/campanha/contatos` (não para o Início blank). Sem migration. Plano: [`inicio-em-branco-quadro.md`](../../../docs/plans/inicio-em-branco-quadro.md). Desbloqueia **B44** / **B45**.
- **Sessão observada CG 2026-07-29 (Bloco B):** 1º hands-on ~54 min. Achados U1–U8 / O9–O11 — não rola; mental model **Ação→Local→Quem**; confirmações invisíveis; forms precisam ser contínuos; hit = lista sem assessor × votos 2022; mapa ≠ mesa de trabalho. Pivot: tabelas=bulk; operação=fluxos lineares tipo Nubank. Snapshot [`sessao-observada-coordenador-2026-07-29-snapshot.md`](../../../docs/plans/sessao-observada-coordenador-2026-07-29-snapshot.md); CUSTOMER.md/OST atualizados. Prazo soft: manejável até reunião assessoria **03/08**. **Chassis:** ~~B43~~ ✓ → **B44** botão/strip → **B45** catálogo por persona (wizards depois).
- **E12+ entregue em código (2026-07-29):** escala/DRY pós-E12 — F1 forçada: `src/lib/metropolitanoTerritoryPeers.ts` unifica constantes, filtros Salvador vs demais RMS e `catalogPeersForSlug` (rollup E17/E12, `slugsForMetropolitanoSubRow`, benchmark T4). F2 não entrou — `benchmarkBySlug` já memoiza por slug. Spec `metropolitanoTerritoryPeers.unit.spec.ts`. Sem migration/UI. Plano: [`escala-dry-pos-e12.md`](../../../docs/plans/escala-dry-pos-e12.md).
diff --git a/docs/plans/botao-acao-inicio-strip.md b/docs/plans/botao-acao-inicio-strip.md
index 214e700a..27284f03 100644
--- a/docs/plans/botao-acao-inicio-strip.md
+++ b/docs/plans/botao-acao-inicio-strip.md
@@ -1,7 +1,7 @@
# Botão de ação do Início + lista horizontal
-Status: rascunho
-Atualizado em: 2026-07-29
+Status: entregue (2026-07-29)
+Atualizado em: 2026-07-29 — as-built: `CampaignHomeActionButton` + `CampaignHomeActionStrip` + `useCampaignLongPress`; Tooltip (fine) / long-press Drawer (coarse via `matchMedia`); sem mount no Início (B45).
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B, item B44 — chassis UX-1)
Impeccable: C — primitivo UI novo (botão circular + strip rolável) no Início blank
Appetite: ~1 dia eng; 2 componentes client + long-press + Tooltip/Drawer; sem migration
diff --git a/docs/roadmap.md b/docs/roadmap.md
index 57627a5f..da5984aa 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -1,6 +1,6 @@
# Roadmap — Teqo
-Atualizado em: 2026-07-29 — B43 entregue (chassis UX-1); B44–B45 pendentes.
+Atualizado em: 2026-07-29 — B44 entregue (botão/strip UX-1); B45 pendente.
Registro canônico dos **próximos** planos e débitos. Histórico de entregas: resumo abaixo + planos em [`docs/plans/`](plans/) + notebook [`.cursor/rules/projects/nucleos-eleitorais.mdc`](../.cursor/rules/projects/nucleos-eleitorais.mdc).
## Âncoras do calendário eleitoral 2026 (Res. TSE 23.760/2026)
@@ -83,9 +83,9 @@ O discovery literatura→persona→entrevista ([relatório aprovado](research/re
### Demais itens abertos
-- **UX-1 fluxos ação-primeiro (prioridade pós-sessão 29/07)** — evidência: [snapshot](plans/sessao-observada-coordenador-2026-07-29-snapshot.md) · O9–O11 em CUSTOMER.md · **rascunho:** [fluxos-acao-primeiro-inicio.md](plans/fluxos-acao-primeiro-inicio.md). Soft deadline: **03/08**. **Chassis:** ~~**B43**~~ ✓ → **B44** → **B45** (botão/strip + catálogo; wizards com escrita = fatias seguintes).
+- **UX-1 fluxos ação-primeiro (prioridade pós-sessão 29/07)** — evidência: [snapshot](plans/sessao-observada-coordenador-2026-07-29-snapshot.md) · O9–O11 em CUSTOMER.md · **rascunho:** [fluxos-acao-primeiro-inicio.md](plans/fluxos-acao-primeiro-inicio.md). Soft deadline: **03/08**. **Chassis:** ~~**B43**~~ ✓ → ~~**B44**~~ ✓ → **B45** (catálogo por persona; wizards com escrita = fatias seguintes).
- ~~**B43** Início em branco + Quadro~~ — **entregue 2026-07-29** (`/campanha` blank; staff dashboard em `/campanha/quadro`; liderança em `/campanha/contatos`; nav Início+Quadro / Início+Meus contatos; `requireCampaignPageActor` manda leader bloqueado → `/campanha/contatos`; sem migration) · [plano](plans/inicio-em-branco-quadro.md)
-- **B44** Botão de ação do Início + lista horizontal — círculo `muted` + ícone + label; strip com scroll interno; descrição opcional Tooltip (fine) / Drawer via long-press (coarse; long-press ≠ click). Depende de B43 · ~1d · Janela 1 · Impeccable C · [plano](plans/botao-acao-inicio-strip.md)
+- ~~**B44** Botão de ação do Início + lista horizontal~~ — **entregue 2026-07-29** (`CampaignHomeActionButton` + `CampaignHomeActionStrip`; Tooltip fine / long-press Drawer coarse; sem mount no Início — B45) · ~1d · Janela 1 · Impeccable C · [plano](plans/botao-acao-inicio-strip.md)
- **B45** Catálogo de ações por persona — CG/candidato e assessor (6 ações; `uncovered-municipalities` já navega); liderança (2; `my-contacts` → `/campanha/contatos`); resto inerte até wizards. Depende de B44 · ~0,5d · Janela 1 · Impeccable B · [plano](plans/catalogo-acoes-inicio-por-persona.md)
- ~~**E4R** import único da planilha de projeção~~ — **entregue 2026-07-24** (`pnpm db:seed:projecao`; overwrite-always; [plano](plans/import-planilha-projecao.md))
- ~~**A11** posição em votos do município (rank absoluto + % da própria votação) + ordenação da lista por votação~~ — **entregue 2026-07-24** (helper `municipalityVoteRank`; baseline + coluna lista; default `?sort=votos` desc; header=`2022`; consome contrato B15) · [plano](plans/ranking-votos-municipio.md)
@@ -188,7 +188,7 @@ flowchart TD
B41n["B41 ✓ Scroll horizontal
+ sticky Município"]
B42n["B42 ✓ Polimento mobile
lista municípios"]
B43n["B43 ✓ Início em branco
+ Quadro / Contatos"]
- B44n["B44 Botão ação
+ strip horizontal"]
+ B44n["B44 ✓ Botão ação
+ strip horizontal"]
B45n["B45 Catálogo ações
por persona"]
B15n["B15 ✓ Ordenar lista
por coluna"]
B16n["B16 ✓ Filtros no header
da lista"]
diff --git a/src/components/campaign/dashboard/CampaignHomeActionButton.tsx b/src/components/campaign/dashboard/CampaignHomeActionButton.tsx
new file mode 100644
index 00000000..accc28b5
--- /dev/null
+++ b/src/components/campaign/dashboard/CampaignHomeActionButton.tsx
@@ -0,0 +1,164 @@
+'use client'
+
+import type { LucideIcon } from 'lucide-react'
+import Link from 'next/link'
+import { useCallback, useId, useRef, useState, type MouseEvent, type ReactNode } from 'react'
+
+import { Button } from '@/components/ui/button'
+import {
+ Drawer,
+ DrawerClose,
+ DrawerContent,
+ DrawerDescription,
+ DrawerFooter,
+ DrawerHeader,
+ DrawerTitle,
+} from '@/components/ui/Drawer'
+import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
+import { useCoarsePointer } from '@/lib/campaignCoarsePointer'
+import { useCampaignLongPress } from '@/lib/campaignLongPress'
+
+export type CampaignHomeActionButtonProps = {
+ label: string
+ icon: LucideIcon
+ description?: string
+ href?: string
+ onClick?: () => void
+ disabled?: boolean
+}
+
+const actionControlClassName =
+ 'group flex min-h-11 w-[4.75rem] shrink-0 snap-start flex-col items-center gap-2 rounded-md text-center outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50'
+
+const ActionVisual = ({ label, icon: Icon }: { label: string; icon: LucideIcon }) => (
+ <>
+
+
+
+
+ {label}
+
+ >
+)
+
+export const CampaignHomeActionButton = ({
+ label,
+ icon,
+ description,
+ href,
+ onClick,
+ disabled = false,
+}: CampaignHomeActionButtonProps) => {
+ const isCoarsePointer = useCoarsePointer()
+ const [drawerOpen, setDrawerOpen] = useState(false)
+ const descriptionId = useId()
+ const titleRef = useRef(null)
+
+ const openDescription = useCallback(() => {
+ if (description) setDrawerOpen(true)
+ }, [description])
+
+ const fireClick = useCallback(
+ (_event: MouseEvent) => {
+ onClick?.()
+ },
+ [onClick],
+ )
+
+ const useLongPress = Boolean(description) && isCoarsePointer && !disabled
+ const longPress = useCampaignLongPress({
+ enabled: useLongPress,
+ onLongPress: openDescription,
+ onClick: href ? undefined : onClick ? fireClick : undefined,
+ })
+
+ const pointerHandlers = useLongPress ? longPress : undefined
+ const className = actionControlClassName
+ const aria = {
+ 'aria-label': label,
+ 'aria-describedby': drawerOpen && useLongPress ? descriptionId : undefined,
+ }
+
+ const pointerProps = pointerHandlers
+ ? {
+ onPointerDown: pointerHandlers.onPointerDown,
+ onPointerMove: pointerHandlers.onPointerMove,
+ onPointerUp: pointerHandlers.onPointerUp,
+ onPointerCancel: pointerHandlers.onPointerCancel,
+ }
+ : undefined
+
+ let control: ReactNode
+
+ if (disabled) {
+ control = (
+
+
+
+ )
+ } else if (href) {
+ control = (
+
+
+
+ )
+ } else {
+ control = (
+
+ )
+ }
+
+ const withTooltip =
+ description && !isCoarsePointer && !disabled ? (
+
+ {control}
+
+ {description}
+
+
+ ) : (
+ control
+ )
+
+ return (
+ <>
+ {withTooltip}
+ {description && isCoarsePointer && !disabled ? (
+
+
+
+
+ {label}
+
+ {description}
+
+
+ }
+ >
+ Fechar
+
+
+
+
+ ) : null}
+ >
+ )
+}
diff --git a/src/components/campaign/dashboard/CampaignHomeActionStrip.tsx b/src/components/campaign/dashboard/CampaignHomeActionStrip.tsx
new file mode 100644
index 00000000..b8441b32
--- /dev/null
+++ b/src/components/campaign/dashboard/CampaignHomeActionStrip.tsx
@@ -0,0 +1,41 @@
+'use client'
+
+import type { ReactNode } from 'react'
+
+import {
+ CampaignHomeActionButton,
+ type CampaignHomeActionButtonProps,
+} from '@/components/campaign/dashboard/CampaignHomeActionButton'
+import { TooltipProvider } from '@/components/ui/tooltip'
+import { cn } from '@/lib/utils'
+
+export const CampaignHomeActionStrip = ({
+ children,
+ actions,
+ ariaLabel = 'Ações rápidas',
+ className,
+}: {
+ children?: ReactNode
+ actions?: readonly CampaignHomeActionButtonProps[]
+ ariaLabel?: string
+ className?: string
+}) => (
+
+
+
+ {children}
+ {actions?.map((action) => (
+ -
+
+
+ ))}
+
+
+
+)
diff --git a/src/lib/campaignCoarsePointer.ts b/src/lib/campaignCoarsePointer.ts
new file mode 100644
index 00000000..3f7ef216
--- /dev/null
+++ b/src/lib/campaignCoarsePointer.ts
@@ -0,0 +1,36 @@
+'use client'
+
+import { useSyncExternalStore } from 'react'
+
+const COARSE_POINTER_QUERY = '(pointer: coarse)'
+
+const listeners = new Set<() => void>()
+let changeListenerAttached = false
+
+const notifyCoarsePointerListeners = () => {
+ for (const listener of listeners) listener()
+}
+
+const subscribeCoarsePointer = (onStoreChange: () => void) => {
+ listeners.add(onStoreChange)
+ if (typeof window !== 'undefined' && !changeListenerAttached) {
+ window.matchMedia(COARSE_POINTER_QUERY).addEventListener('change', notifyCoarsePointerListeners)
+ changeListenerAttached = true
+ }
+ return () => {
+ listeners.delete(onStoreChange)
+ }
+}
+
+const getCoarsePointerSnapshot = () =>
+ typeof window === 'undefined' ? false : window.matchMedia(COARSE_POINTER_QUERY).matches
+
+/** @internal Test harness resets `matchMedia` between fine/coarse cases. */
+export const resetCampaignCoarsePointerForTests = () => {
+ listeners.clear()
+ changeListenerAttached = false
+}
+
+/** One shared `change` listener; snapshot re-reads `matchMedia` each time. */
+export const useCoarsePointer = () =>
+ useSyncExternalStore(subscribeCoarsePointer, getCoarsePointerSnapshot, () => false)
diff --git a/src/lib/campaignLongPress.ts b/src/lib/campaignLongPress.ts
new file mode 100644
index 00000000..4feddb57
--- /dev/null
+++ b/src/lib/campaignLongPress.ts
@@ -0,0 +1,95 @@
+'use client'
+
+import { useCallback, useEffect, useRef, type MouseEvent, type PointerEvent } from 'react'
+
+export const CAMPAIGN_LONG_PRESS_MS = 450
+const CAMPAIGN_LONG_PRESS_SLOP_PX = 10
+
+type CampaignLongPressPointerHandlers = {
+ onPointerDown: (event: PointerEvent) => void
+ onPointerMove: (event: PointerEvent) => void
+ onPointerUp: (event: PointerEvent) => void
+ onPointerCancel: (event: PointerEvent) => void
+ onClick: (event: MouseEvent) => void
+}
+
+/**
+ * Long-press for coarse-pointer description drawers (B44). Short tap still
+ * reaches `onClick`; the synthetic click after a long-press is suppressed.
+ */
+export const useCampaignLongPress = ({
+ enabled,
+ onLongPress,
+ onClick,
+}: {
+ enabled: boolean
+ onLongPress: () => void
+ onClick?: (event: MouseEvent) => void
+}): CampaignLongPressPointerHandlers => {
+ const suppressClickRef = useRef(false)
+ const timerRef = useRef | null>(null)
+ const originRef = useRef<{ x: number; y: number } | null>(null)
+
+ const clearTimer = useCallback(() => {
+ if (timerRef.current !== null) {
+ clearTimeout(timerRef.current)
+ timerRef.current = null
+ }
+ }, [])
+
+ const onPointerDown = useCallback(
+ (event: PointerEvent) => {
+ if (!enabled || event.button !== 0) return
+ originRef.current = { x: event.clientX, y: event.clientY }
+ clearTimer()
+ timerRef.current = setTimeout(() => {
+ timerRef.current = null
+ originRef.current = null
+ suppressClickRef.current = true
+ onLongPress()
+ }, CAMPAIGN_LONG_PRESS_MS)
+ },
+ [clearTimer, enabled, onLongPress],
+ )
+
+ const onPointerMove = useCallback(
+ (event: PointerEvent) => {
+ if (!originRef.current || timerRef.current === null) return
+ const dx = event.clientX - originRef.current.x
+ const dy = event.clientY - originRef.current.y
+ if (Math.hypot(dx, dy) > CAMPAIGN_LONG_PRESS_SLOP_PX) {
+ clearTimer()
+ originRef.current = null
+ }
+ },
+ [clearTimer],
+ )
+
+ const endPointer = useCallback(() => {
+ clearTimer()
+ originRef.current = null
+ }, [clearTimer])
+
+ useEffect(() => () => clearTimer(), [clearTimer])
+
+ const onClickHandler = useCallback(
+ (event: MouseEvent) => {
+ if (suppressClickRef.current) {
+ event.preventDefault()
+ event.stopPropagation()
+ suppressClickRef.current = false
+ return
+ }
+ onClick?.(event)
+ },
+ [onClick],
+ )
+
+ return {
+ onPointerDown,
+ onPointerMove,
+ onPointerUp: endPointer,
+ onPointerCancel: endPointer,
+ onClick: onClickHandler,
+ }
+}
diff --git a/tests/unit/campaignHomeActionButton.unit.spec.tsx b/tests/unit/campaignHomeActionButton.unit.spec.tsx
new file mode 100644
index 00000000..c913b50b
--- /dev/null
+++ b/tests/unit/campaignHomeActionButton.unit.spec.tsx
@@ -0,0 +1,140 @@
+import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
+import { BarChart3 } from 'lucide-react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+import {
+ CampaignHomeActionButton,
+ type CampaignHomeActionButtonProps,
+} from '@/components/campaign/dashboard/CampaignHomeActionButton'
+import { CampaignHomeActionStrip } from '@/components/campaign/dashboard/CampaignHomeActionStrip'
+import { TooltipProvider } from '@/components/ui/tooltip'
+import { CAMPAIGN_LONG_PRESS_MS } from '@/lib/campaignLongPress'
+import { resetCampaignCoarsePointerForTests } from '@/lib/campaignCoarsePointer'
+
+vi.mock('next/link', () => ({
+ default: ({
+ children,
+ href,
+ onClick,
+ ...props
+ }: {
+ children: React.ReactNode
+ href: string
+ onClick?: (event: React.MouseEvent) => void
+ }) => (
+
+ {children}
+
+ ),
+}))
+
+const matchMediaMock = vi.fn()
+
+beforeEach(() => {
+ resetCampaignCoarsePointerForTests()
+ matchMediaMock.mockImplementation((query: string) => ({
+ matches: false,
+ media: query,
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ }))
+ vi.stubGlobal('matchMedia', matchMediaMock)
+})
+
+afterEach(() => {
+ cleanup()
+ vi.unstubAllGlobals()
+})
+
+const renderActionButton = (props: CampaignHomeActionButtonProps) =>
+ render(
+
+
+ ,
+ )
+
+describe('CampaignHomeActionButton', () => {
+ it('exposes the label as the accessible name', () => {
+ renderActionButton({ label: 'Registrar sinal', icon: BarChart3 })
+ expect(screen.getByRole('button', { name: 'Registrar sinal' })).toBeTruthy()
+ })
+
+ it('fires onClick on a short tap when there is no description', () => {
+ const onClick = vi.fn()
+ renderActionButton({ label: 'Registrar', icon: BarChart3, onClick })
+
+ fireEvent.click(screen.getByRole('button', { name: 'Registrar' }))
+ expect(onClick).toHaveBeenCalledTimes(1)
+ })
+
+ it('renders an href on the link when provided', () => {
+ renderActionButton({
+ label: 'Ver cobertura',
+ icon: BarChart3,
+ description: 'Abre a lista filtrada.',
+ href: '/campanha/municipios',
+ })
+
+ const link = screen.getByRole('link', { name: 'Ver cobertura' })
+ expect(link.getAttribute('href')).toBe('/campanha/municipios')
+ })
+
+ it('marks disabled actions without a focusable control', () => {
+ renderActionButton({ label: 'Em breve', icon: BarChart3, disabled: true })
+ expect(screen.getByLabelText('Em breve').getAttribute('aria-disabled')).toBe('true')
+ })
+
+ it('opens the description drawer on long-press when the pointer is coarse', () => {
+ 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(),
+ }))
+
+ vi.useFakeTimers()
+ const onClick = vi.fn()
+ renderActionButton({
+ label: 'Ver cobertura',
+ icon: BarChart3,
+ description: 'Lista filtrada.',
+ onClick,
+ })
+
+ const target = screen.getByRole('button', { name: 'Ver cobertura' })
+ fireEvent.pointerDown(target, { button: 0, clientX: 0, clientY: 0 })
+ act(() => {
+ vi.advanceTimersByTime(CAMPAIGN_LONG_PRESS_MS)
+ })
+ expect(screen.getByText('Lista filtrada.')).toBeTruthy()
+
+ fireEvent.pointerUp(target)
+ fireEvent.click(target)
+ expect(onClick).not.toHaveBeenCalled()
+ vi.useRealTimers()
+ })
+})
+
+describe('CampaignHomeActionStrip', () => {
+ it('renders a horizontal scroller with list semantics', () => {
+ const { container } = render(
+ ,
+ )
+
+ const scroller = container.querySelector('[aria-label="Ações rápidas"]')
+ expect(scroller?.className).toContain('overflow-x-auto')
+ expect(scroller?.querySelector('ul[role="list"]')).toBeTruthy()
+ })
+})
diff --git a/tests/unit/campaignLongPress.unit.spec.tsx b/tests/unit/campaignLongPress.unit.spec.tsx
new file mode 100644
index 00000000..9cbf7a44
--- /dev/null
+++ b/tests/unit/campaignLongPress.unit.spec.tsx
@@ -0,0 +1,103 @@
+import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+import { CAMPAIGN_LONG_PRESS_MS, useCampaignLongPress } from '@/lib/campaignLongPress'
+const LongPressHarness = ({
+ onLongPress,
+ onClick,
+}: {
+ onLongPress: () => void
+ onClick?: () => void
+}) => {
+ const handlers = useCampaignLongPress({
+ enabled: true,
+ onLongPress,
+ onClick,
+ })
+ return (
+
+ )
+}
+
+beforeEach(() => {
+ vi.useFakeTimers()
+})
+
+afterEach(() => {
+ cleanup()
+ vi.useRealTimers()
+})
+
+describe('useCampaignLongPress', () => {
+ it('calls onLongPress after the threshold and suppresses the following click', () => {
+ const onLongPress = vi.fn()
+ const onClick = vi.fn()
+ render()
+
+ const target = screen.getByRole('button', { name: 'Alvo' })
+ fireEvent.pointerDown(target, { button: 0, clientX: 0, clientY: 0 })
+ act(() => {
+ vi.advanceTimersByTime(CAMPAIGN_LONG_PRESS_MS)
+ })
+ expect(onLongPress).toHaveBeenCalledTimes(1)
+
+ fireEvent.pointerUp(target)
+ fireEvent.click(target)
+ expect(onClick).not.toHaveBeenCalled()
+ })
+
+ it('calls onClick on a short tap', () => {
+ const onLongPress = vi.fn()
+ const onClick = vi.fn()
+ render()
+
+ const target = screen.getByRole('button', { name: 'Alvo' })
+ fireEvent.pointerDown(target, { button: 0, clientX: 0, clientY: 0 })
+ fireEvent.pointerUp(target)
+ fireEvent.click(target)
+
+ expect(onLongPress).not.toHaveBeenCalled()
+ expect(onClick).toHaveBeenCalledTimes(1)
+ })
+
+ it('aborts when the pointer moves beyond slop', () => {
+ const onLongPress = vi.fn()
+ render()
+
+ const target = screen.getByRole('button', { name: 'Alvo' })
+ fireEvent.pointerDown(target, { button: 0, clientX: 0, clientY: 0 })
+ fireEvent.pointerMove(target, { clientX: 24, clientY: 0 })
+ act(() => {
+ vi.advanceTimersByTime(CAMPAIGN_LONG_PRESS_MS)
+ })
+ expect(onLongPress).not.toHaveBeenCalled()
+ })
+
+ it('clears the timer on pointercancel', () => {
+ const onLongPress = vi.fn()
+ render()
+
+ const target = screen.getByRole('button', { name: 'Alvo' })
+ fireEvent.pointerDown(target, { button: 0, clientX: 0, clientY: 0 })
+ fireEvent.pointerCancel(target)
+ act(() => {
+ vi.advanceTimersByTime(CAMPAIGN_LONG_PRESS_MS)
+ })
+ expect(onLongPress).not.toHaveBeenCalled()
+ })
+
+ it('does not call onLongPress after unmount during the hold', () => {
+ const onLongPress = vi.fn()
+ const { unmount } = render()
+
+ const target = screen.getByRole('button', { name: 'Alvo' })
+ fireEvent.pointerDown(target, { button: 0, clientX: 0, clientY: 0 })
+ unmount()
+ act(() => {
+ vi.advanceTimersByTime(CAMPAIGN_LONG_PRESS_MS)
+ })
+ expect(onLongPress).not.toHaveBeenCalled()
+ })
+})