B58: polish Início action strip and centralize coarse press state.

Short staff labels, hidden scrollbar, circle feedback on hover/press, and long-press pressing lives in useCampaignLongPress for reuse.
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-29 16:09:57 -03:00
parent 3581880950
commit 274cde65a7
11 changed files with 130 additions and 87 deletions

View file

@ -7,6 +7,7 @@ alwaysApply: false
## Status
- **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.
- **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 @@
# Polimento visual da strip de ações do Início
Status: rascunho
Atualizado em: 2026-07-29
Status: entregue (2026-07-29)
Atualizado em: 2026-07-29 — as-built: rótulos curtos no catálogo; sem `h2` em `CampaignHomeActions`; scrollbar oculta na strip; escala no círculo (hover fine / press coarse) + `active:opacity-90`; e2e retarget de heading para `[data-slot="home-actions"]` / `Ações rápidas`.
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B — **B58**; chassis UX-1)
Impeccable: B — encaixe na strip existente (`CampaignHomeActionButton` / `CampaignHomeActionStrip` / `campaignHomeActions.ts`)
Appetite: ~0,5 dia eng (sem migration, sem action, sem URL)

View file

@ -1,6 +1,6 @@
# Roadmap — Teqo
Atualizado em: 2026-07-29 — **B65** ancorar busca+ações no limite inferior mobile (B46 ✓ não ancorou); B48 ✓; B64; B59–B63; B47 ✓; B58; B49–B55.
Atualizado em: 2026-07-29 — ~~**B58**~~ ✓ (polimento strip Início); **B65** ancorar busca+ações no limite inferior mobile (B46 ✓ não ancorou); B48 ✓; B64; B59–B63; B47 ✓; B49–B55.
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,11 +83,11 @@ 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 Início:** ~~**B43**~~ ✓ → ~~**B44**~~ ✓ → ~~**B45**~~ ✓ → ~~**B46**~~ ✓ → ~~**B47**~~ ✓ → **B65** (âncora inferior mobile — B46 não ancorou) → **B58** (polimento strip) → ~~**B48**~~ ✓ → **B49–B55** (demais resultados busca) · **B56** (resumo) · **B57** delta 7d = futuro. **Wizards (etapas):** **B59** chassis → **B60** busca município · **B61** ajuste votos · **B62** simplificar sinal → **B63** wizard sinal · **B64** wizard tendência; wiring A1/A2 depois.
- **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 Início:** ~~**B43**~~ ✓ → ~~**B44**~~ ✓ → ~~**B45**~~ ✓ → ~~**B46**~~ ✓ → ~~**B47**~~ ✓ → ~~**B58**~~ ✓ → **B65** (âncora inferior mobile — B46 não ancorou) → ~~**B48**~~ ✓ → **B49–B55** (demais resultados busca) · **B56** (resumo) · **B57** delta 7d = futuro. **Wizards (etapas):** **B59** chassis → **B60** busca município · **B61** ajuste votos · **B62** simplificar sinal → **B63** wizard sinal · **B64** wizard tendência; wiring A1/A2 depois.
- ~~**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~~ — **entregue 2026-07-29** (`CampaignHomeActionButton` + `CampaignHomeActionStrip`; Tooltip fine / long-press Drawer coarse; montado no Início por B45 ✓) · ~1d · Janela 1 · Impeccable C · [plano](plans/botao-acao-inicio-strip.md)
- ~~**B45** Catálogo de ações por persona~~ — **entregue 2026-07-29** (`src/lib/campaignHomeActions.ts` + `CampaignHomeActions`; staff 6 ações com `uncovered-municipalities` → `?coverage=sem_assessor&sort=votos`; leader 2 com `my-contacts` → `/campanha/contatos`; demais inertes; ilha client por limite RSC↔ícones Lucide) · [plano](plans/catalogo-acoes-inicio-por-persona.md)
- **B58** Polimento visual da strip de ações do Início — remover heading "O que você quer fazer?"; rótulos curtos (Ajustar votos, Registrar sinal, …; atalho → **"Ver esquecidos"** _(proposto — validar com produto)_); scale discreto no círculo (hover/long-press) + opacity no active; scrollbar horizontal oculta (pan preservado) · Depende de B45 ✓ · ~0,5d · Janela 1 · Paralelizável com ~~B46~~ ✓ · Impeccable B · [plano](plans/polimento-strip-acoes-inicio.md)
- **B58** Polimento visual da strip de ações do Início — ~~remover heading "O que você quer fazer?"; rótulos curtos (Ajustar votos, Registrar sinal, …; atalho → **"Ver esquecidos"** _(proposto — validar com produto)_); scale discreto no círculo (hover/long-press) + opacity no active; scrollbar horizontal oculta (pan preservado)~~ — **entregue 2026-07-29** (catálogo + strip + botão; e2e atualizado) · Depende de B45 ✓ · ~0,5d · Janela 1 · Impeccable B · [plano](plans/polimento-strip-acoes-inicio.md)
- **B59** Chassis do wizard (shell de passo + seta Voltar só mobile; desktop = browser) · Depende de B45 ✓ · ~0,75d · Janela 1 · Impeccable C · [plano](plans/chassis-wizard-campanha.md)
- **B60** Etapa busca município nos wizards (UX da busca do Início, só municípios; select → avança) · Depende de B59 · soft B48 · ~1d · Janela 1 · Impeccable B · [plano](plans/busca-municipio-wizard.md)
- **B61** Etapa ajuste de votos (média→pessimista→otimista; atalhos 2×/±50/±100; coerência com jump+warning) · Depende de B59 · soft B60 · ~1–1,25d · Janela 1 · Impeccable C · [plano](plans/ajuste-votos-wizard.md)

View file

@ -29,14 +29,14 @@ export type CampaignHomeActionButtonProps = {
}
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'
'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 active:opacity-90 disabled:pointer-events-none disabled:opacity-50'
const circleClassName =
'flex size-14 items-center justify-center rounded-full bg-muted text-foreground transition-[transform,colors] duration-150 ease-out motion-reduce:transition-none pointer-fine:group-hover:scale-[1.05] pointer-coarse:group-data-[pressing=true]:scale-[1.05] motion-reduce:scale-100 group-hover:bg-muted/80 group-focus-visible:bg-muted/80'
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"
>
<span aria-hidden className={circleClassName}>
<Icon className="size-6" strokeWidth={1.75} />
</span>
<span className="line-clamp-2 w-full text-sm leading-snug font-medium text-foreground">
@ -62,34 +62,29 @@ export const CampaignHomeActionButton = ({
if (description) setDrawerOpen(true)
}, [description])
const fireClick = useCallback(
(_event: MouseEvent) => {
onClick?.()
},
[onClick],
)
const useLongPress = Boolean(description) && isCoarsePointer && !disabled
const longPressEnabled = Boolean(description) && isCoarsePointer && !disabled
const isInert = !disabled && !href && !onClick
const longPress = useCampaignLongPress({
enabled: useLongPress,
const { pressing, ...longPressHandlers } = useCampaignLongPress({
enabled: longPressEnabled,
onLongPress: openDescription,
onClick: href ? undefined : onClick ? fireClick : undefined,
onClick:
href || !onClick
? undefined
: (_event: MouseEvent) => {
onClick()
},
})
const pointerHandlers = useLongPress ? longPress : undefined
const className = cn(actionControlClassName, isInert && 'cursor-default')
const aria = {
'aria-label': label,
'aria-describedby': drawerOpen && useLongPress ? descriptionId : undefined,
'aria-describedby': drawerOpen && longPressEnabled ? descriptionId : undefined,
}
const pointerProps = pointerHandlers
const longPressProps = longPressEnabled
? {
onPointerDown: pointerHandlers.onPointerDown,
onPointerMove: pointerHandlers.onPointerMove,
onPointerUp: pointerHandlers.onPointerUp,
onPointerCancel: pointerHandlers.onPointerCancel,
...longPressHandlers,
'data-pressing': pressing ? true : undefined,
}
: undefined
@ -103,13 +98,7 @@ export const CampaignHomeActionButton = ({
)
} else if (href) {
control = (
<Link
href={href}
className={className}
{...aria}
{...pointerProps}
onClick={pointerHandlers?.onClick}
>
<Link href={href} className={className} {...aria} {...longPressProps}>
<ActionVisual label={label} icon={icon} />
</Link>
)
@ -119,8 +108,16 @@ export const CampaignHomeActionButton = ({
type="button"
className={className}
{...aria}
{...pointerProps}
onClick={pointerHandlers?.onClick ?? (onClick ? fireClick : undefined)}
{...longPressProps}
onClick={
longPressEnabled
? longPressHandlers.onClick
: onClick
? () => {
onClick()
}
: undefined
}
>
<ActionVisual label={label} icon={icon} />
</button>

View file

@ -1,7 +1,5 @@
'use client'
import type { ReactNode } from 'react'
import {
CampaignHomeActionButton,
type CampaignHomeActionButtonProps,
@ -10,12 +8,10 @@ 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 & { id?: string })[]
ariaLabel?: string
className?: string
@ -24,12 +20,11 @@ export const CampaignHomeActionStrip = ({
<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]',
'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">
{children}
{actions?.map(({ id, ...button }) => (
<li key={id ?? button.href ?? button.label} className="m-0 list-none p-0">
<CampaignHomeActionButton {...button} />

View file

@ -4,8 +4,6 @@ import { CampaignHomeActionStrip } from '@/components/campaign/dashboard/Campaig
import { homeActionsForRole, toHomeActionButtonProps } from '@/lib/campaignHomeActions'
import type { CampaignRole } from '@/lib/campaignRoles'
const homeActionsHeadingId = 'campaign-home-actions-heading'
export const CampaignHomeActions = ({
role,
uncoveredMunicipalitiesHref,
@ -17,12 +15,5 @@ export const CampaignHomeActions = ({
if (actions.length === 0) return null
return (
<section aria-labelledby={homeActionsHeadingId} className="w-full">
<h2 id={homeActionsHeadingId} className="text-base font-semibold text-foreground">
O que você quer fazer?
</h2>
<CampaignHomeActionStrip actions={actions} className="mt-4 w-full" />
</section>
)
return <CampaignHomeActionStrip actions={actions} className="w-full" />
}

View file

@ -34,13 +34,13 @@ export type CampaignHomeAction = {
const staffHomeActionsCoordinator: readonly CampaignHomeAction[] = [
{
id: 'update-votes',
label: 'Atualizar votos de um município',
label: 'Ajustar votos',
icon: BarChart3,
description: 'Atualizar a projeção (média, pessimista, otimista) de um município',
},
{
id: 'register-signal',
label: 'Registrar o que aconteceu',
label: 'Registrar sinal',
icon: Megaphone,
description: 'Anotar sinal urgente: invasão, perda de apoio, novo apoio, dificuldade',
},
@ -64,7 +64,7 @@ const staffHomeActionsCoordinator: readonly CampaignHomeAction[] = [
},
{
id: 'uncovered-municipalities',
label: 'Ver quem ainda não está coberto',
label: 'Ver esquecidos',
icon: UserRoundSearch,
description: 'Abrir a lista de municípios sem assessor (atalho — não é wizard)',
},

View file

@ -1,11 +1,19 @@
'use client'
import { useCallback, useEffect, useRef, type MouseEvent, type PointerEvent } from 'react'
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type MouseEvent,
type PointerEvent,
} from 'react'
export const CAMPAIGN_LONG_PRESS_MS = 450
const CAMPAIGN_LONG_PRESS_SLOP_PX = 10
type CampaignLongPressPointerHandlers = {
export type CampaignLongPressPointerHandlers = {
onPointerDown: (event: PointerEvent) => void
onPointerMove: (event: PointerEvent) => void
onPointerUp: (event: PointerEvent) => void
@ -13,6 +21,11 @@ type CampaignLongPressPointerHandlers = {
onClick: (event: MouseEvent) => void
}
export type CampaignLongPressResult = CampaignLongPressPointerHandlers & {
/** True while the primary pointer is down (only when `enabled`). */
pressing: boolean
}
/**
* Long-press for coarse-pointer description drawers (B44). Short tap still
* reaches `onClick`; the synthetic click after a long-press is suppressed.
@ -25,7 +38,8 @@ export const useCampaignLongPress = ({
enabled: boolean
onLongPress: () => void
onClick?: (event: MouseEvent) => void
}): CampaignLongPressPointerHandlers => {
}): CampaignLongPressResult => {
const [pressing, setPressing] = useState(false)
const suppressClickRef = useRef(false)
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const originRef = useRef<{ x: number; y: number } | null>(null)
@ -37,9 +51,16 @@ export const useCampaignLongPress = ({
}
}, [])
const releasePress = useCallback(() => {
clearTimer()
originRef.current = null
setPressing(false)
}, [clearTimer])
const onPointerDown = useCallback(
(event: PointerEvent) => {
if (!enabled || event.button !== 0) return
setPressing(true)
originRef.current = { x: event.clientX, y: event.clientY }
clearTimer()
timerRef.current = setTimeout(() => {
@ -65,11 +86,6 @@ export const useCampaignLongPress = ({
[clearTimer],
)
const endPointer = useCallback(() => {
clearTimer()
originRef.current = null
}, [clearTimer])
useEffect(() => () => clearTimer(), [clearTimer])
const onClickHandler = useCallback(
@ -85,11 +101,15 @@ export const useCampaignLongPress = ({
[onClick],
)
return {
onPointerDown,
onPointerMove,
onPointerUp: endPointer,
onPointerCancel: endPointer,
onClick: onClickHandler,
}
return useMemo(
() => ({
pressing: enabled && pressing,
onPointerDown,
onPointerMove,
onPointerUp: releasePress,
onPointerCancel: releasePress,
onClick: onClickHandler,
}),
[enabled, onClickHandler, onPointerDown, onPointerMove, pressing, releasePress],
)
}

View file

@ -1,12 +1,12 @@
import { expect, test } from './fixtures/campaignE2EFixtures.js'
const staffActionLabels = [
'Atualizar votos de um município',
'Registrar o que aconteceu',
'Ajustar votos',
'Registrar sinal',
'Mudar tendência',
'Atualizar liderança',
'Registrar pedido',
'Ver quem ainda não está coberto',
'Ver esquecidos',
] as const
test.describe('Início — busca global (B47)', () => {
@ -37,14 +37,15 @@ test.describe('Início — busca global (B47)', () => {
})
await campaign.login(page, coordinator.email!, coordinator.password)
const heading = page.getByRole('heading', { name: 'O que você quer fazer?', exact: true })
await expect(heading).toBeVisible()
const actionsSlot = page.locator('[data-slot="home-actions"]')
await expect(actionsSlot).toBeVisible()
await expect(page.getByLabel('Ações rápidas')).toBeVisible()
await page.getByLabel('Buscar na campanha').fill('ca')
await expect(heading).toBeHidden({ timeout: 5000 })
await expect(actionsSlot).toBeHidden({ timeout: 5000 })
await page.getByLabel('Buscar na campanha').fill('')
await expect(heading).toBeVisible({ timeout: 5000 })
await expect(actionsSlot).toBeVisible({ timeout: 5000 })
})
test('staff search shows municipality hits and opens detail (B48)', async ({
@ -80,9 +81,7 @@ test.describe('Início — catálogo de ações (B45)', () => {
})
await campaign.login(page, coordinator.email!, coordinator.password)
await expect(
page.getByRole('heading', { name: 'O que você quer fazer?', exact: true }),
).toBeVisible()
await expect(page.getByLabel('Ações rápidas')).toBeVisible()
for (const label of staffActionLabels) {
const link = page.getByRole('link', { name: label, exact: true })
@ -90,7 +89,7 @@ test.describe('Início — catálogo de ações (B45)', () => {
await expect(link.or(button)).toBeVisible()
}
await page.getByRole('link', { name: 'Ver quem ainda não está coberto', exact: true }).click()
await page.getByRole('link', { name: 'Ver esquecidos', exact: true }).click()
await page.waitForURL(/\/campanha\/municipios/)
const url = new URL(page.url())
expect(url.searchParams.get('coverage')).toBe('sem_assessor')
@ -104,9 +103,7 @@ test.describe('Início — catálogo de ações (B45)', () => {
})
await campaign.login(page, leader.email!, leader.password)
await expect(
page.getByRole('heading', { name: 'O que você quer fazer?', exact: true }),
).toBeVisible()
await expect(page.getByLabel('Ações rápidas')).toBeVisible()
await expect(
page.getByRole('button', { name: 'Cadastrar apoiador', exact: true }),

View file

@ -120,6 +120,33 @@ describe('CampaignHomeActionButton', () => {
expect(onClick).not.toHaveBeenCalled()
vi.useRealTimers()
})
it('sets data-pressing on coarse pointer down before long-press fires', () => {
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(),
}))
renderActionButton({
label: 'Ver cobertura',
icon: BarChart3,
description: 'Lista filtrada.',
href: '/campanha/municipios',
})
const target = screen.getByRole('link', { name: 'Ver cobertura' })
expect(target.getAttribute('data-pressing')).toBeNull()
fireEvent.pointerDown(target, { button: 0, clientX: 0, clientY: 0 })
expect(target.getAttribute('data-pressing')).toBe('true')
fireEvent.pointerUp(target)
expect(target.getAttribute('data-pressing')).toBeNull()
})
})
describe('CampaignHomeActionStrip', () => {
@ -135,6 +162,7 @@ describe('CampaignHomeActionStrip', () => {
const scroller = container.querySelector('[aria-label="Ações rápidas"]')
expect(scroller?.className).toContain('overflow-x-auto')
expect(scroller?.className).toContain('scrollbar-width:none')
expect(scroller?.querySelector('ul[role="list"]')).toBeTruthy()
})
})

View file

@ -9,13 +9,13 @@ const LongPressHarness = ({
onLongPress: () => void
onClick?: () => void
}) => {
const handlers = useCampaignLongPress({
const { pressing, ...handlers } = useCampaignLongPress({
enabled: true,
onLongPress,
onClick,
})
return (
<button type="button" {...handlers}>
<button type="button" data-pressing={pressing ? true : undefined} {...handlers}>
Alvo
</button>
)
@ -88,6 +88,20 @@ describe('useCampaignLongPress', () => {
expect(onLongPress).not.toHaveBeenCalled()
})
it('sets pressing while the primary pointer is down', () => {
const onLongPress = vi.fn()
render(<LongPressHarness onLongPress={onLongPress} />)
const target = screen.getByRole('button', { name: 'Alvo' })
expect(target.getAttribute('data-pressing')).toBeNull()
fireEvent.pointerDown(target, { button: 0, clientX: 0, clientY: 0 })
expect(target.getAttribute('data-pressing')).toBe('true')
fireEvent.pointerUp(target)
expect(target.getAttribute('data-pressing')).toBeNull()
})
it('does not call onLongPress after unmount during the hold', () => {
const onLongPress = vi.fn()
const { unmount } = render(<LongPressHarness onLongPress={onLongPress} />)