B44: UX-1 home action button and horizontal strip primitives.

Tooltip on fine pointer and long-press drawer on coarse without mounting on Início yet (B45).
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-29 14:20:52 -03:00
parent 69e7af7fa8
commit f03a28fd59
9 changed files with 586 additions and 6 deletions

View file

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

View file

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

View file

@ -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<br/>+ sticky Município"]
B42n["B42 ✓ Polimento mobile<br/>lista municípios"]
B43n["B43 ✓ Início em branco<br/>+ Quadro / Contatos"]
B44n["B44 Botão ação<br/>+ strip horizontal"]
B44n["B44 ✓ Botão ação<br/>+ strip horizontal"]
B45n["B45 Catálogo ações<br/>por persona"]
B15n["B15 ✓ Ordenar lista<br/>por coluna"]
B16n["B16 ✓ Filtros no header<br/>da lista"]

View file

@ -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 }) => (
<>
<span
aria-hidden
className="flex size-14 items-center justify-center rounded-full bg-muted text-foreground transition-colors group-hover:bg-muted/80 group-focus-visible:bg-muted/80"
>
<Icon className="size-6" strokeWidth={1.75} />
</span>
<span className="line-clamp-2 w-full text-sm leading-snug font-medium text-foreground">
{label}
</span>
</>
)
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<HTMLHeadingElement | null>(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 = (
<span className={className} aria-disabled="true" {...aria}>
<ActionVisual label={label} icon={icon} />
</span>
)
} else if (href) {
control = (
<Link
href={href}
className={className}
{...aria}
{...pointerProps}
onClick={pointerHandlers?.onClick}
>
<ActionVisual label={label} icon={icon} />
</Link>
)
} else {
control = (
<button
type="button"
className={className}
{...aria}
{...pointerProps}
onClick={pointerHandlers?.onClick ?? (onClick ? fireClick : undefined)}
>
<ActionVisual label={label} icon={icon} />
</button>
)
}
const withTooltip =
description && !isCoarsePointer && !disabled ? (
<Tooltip>
<TooltipTrigger asChild>{control}</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-xs text-left font-normal">
{description}
</TooltipContent>
</Tooltip>
) : (
control
)
return (
<>
{withTooltip}
{description && isCoarsePointer && !disabled ? (
<Drawer open={drawerOpen} onOpenChange={setDrawerOpen}>
<DrawerContent initialFocus={titleRef}>
<DrawerHeader>
<DrawerTitle ref={titleRef} tabIndex={-1} className="outline-none">
{label}
</DrawerTitle>
<DrawerDescription id={descriptionId}>{description}</DrawerDescription>
</DrawerHeader>
<DrawerFooter>
<DrawerClose
render={<Button type="button" variant="outline" className="min-h-11 w-full" />}
>
Fechar
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
) : null}
</>
)
}

View file

@ -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
}) => (
<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:thin]',
className,
)}
>
<ul role="list" className="m-0 flex min-w-max list-none snap-x snap-proximity gap-6 p-0 pb-1">
{children}
{actions?.map((action) => (
<li key={action.href ?? action.label} className="m-0 list-none p-0">
<CampaignHomeActionButton {...action} />
</li>
))}
</ul>
</div>
</TooltipProvider>
)

View file

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

View file

@ -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<ReturnType<typeof setTimeout> | 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,
}
}

View file

@ -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
}) => (
<a href={href} onClick={onClick} {...props}>
{children}
</a>
),
}))
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(
<TooltipProvider>
<CampaignHomeActionButton {...props} />
</TooltipProvider>,
)
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(
<CampaignHomeActionStrip
actions={[
{ label: 'Um', icon: BarChart3 },
{ label: 'Dois', icon: BarChart3 },
]}
/>,
)
const scroller = container.querySelector('[aria-label="Ações rápidas"]')
expect(scroller?.className).toContain('overflow-x-auto')
expect(scroller?.querySelector('ul[role="list"]')).toBeTruthy()
})
})

View file

@ -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 (
<button type="button" {...handlers}>
Alvo
</button>
)
}
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(<LongPressHarness onLongPress={onLongPress} onClick={onClick} />)
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(<LongPressHarness onLongPress={onLongPress} onClick={onClick} />)
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(<LongPressHarness onLongPress={onLongPress} />)
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(<LongPressHarness onLongPress={onLongPress} />)
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(<LongPressHarness onLongPress={onLongPress} />)
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()
})
})