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..fb656723 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 @@ -94,6 +94,16 @@ Componentes: - **Drag-to-scroll no desktop.** Revisitar se CG não descobrir overflow na strip. - **Fade edges** na strip. Revisitar no critique Impeccable se o corte visual mentir “acabou”. +- **Lazy mount do Drawer** (só no primeiro long-press). Trade-off B34/B42: Drawer coarse montado por ação com `description`; revisitar se o Início (B45) com 6 ações perfilar DOM pesado. +- **Shell read-only “info drawer”** compartilhado com `CampaignCellEditOverlay` / `InstallPwaToast`. Gatilho: **2º** call site idêntico (título + descrição + Fechar). +- **`children` da strip sem `
  • `.** Documentar ou validar quando B45 montar ações custom. +- **Mover `useCampaignLongPress` para fora de `lib/`.** Gatilho: 2º call site idêntico (plano já prevê extrair no 2º). + +## Pós-simplify (2026-07-29, não reabrir) + +**Já resolvido na entrega:** timer long-press com cleanup no unmount; `aria-describedby` só com drawer aberto; `useCoarsePointer` com um listener `matchMedia` compartilhado; `href` sem `onClick` no hook (navegação = tap curto); Drawer omitido quando `disabled`; keys `href ?? label`; specs duplicadas `.ts` removidas; testes coarse long-press + unmount. + +**Descartado (score baixo):** constante de classe Tooltip compartilhada com `CampaignHoverTooltip`; e2e de scroll do `body` além de smoke de classes na strip. ## Referências 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..753fffe8 --- /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 { resetCampaignCoarsePointerForTests } from '@/lib/campaignCoarsePointer' +import { CAMPAIGN_LONG_PRESS_MS } from '@/lib/campaignLongPress' + +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() + }) +})