From 274cde65a7c72a8789ada8d3b375984bde2d149b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Wed, 29 Jul 2026 16:09:57 -0300 Subject: [PATCH] =?UTF-8?q?B58:=20polish=20In=C3=ADcio=20action=20strip=20?= =?UTF-8?q?and=20centralize=20coarse=20press=20state.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Short staff labels, hidden scrollbar, circle feedback on hover/press, and long-press pressing lives in useCampaignLongPress for reuse. --- .cursor/rules/projects/nucleos-eleitorais.mdc | 1 + docs/plans/polimento-strip-acoes-inicio.md | 4 +- docs/roadmap.md | 6 +- .../dashboard/CampaignHomeActionButton.tsx | 61 +++++++++---------- .../dashboard/CampaignHomeActionStrip.tsx | 7 +-- .../dashboard/CampaignHomeActions.tsx | 11 +--- src/lib/campaignHomeActions.ts | 6 +- src/lib/campaignLongPress.ts | 50 ++++++++++----- tests/e2e/campaignHomeActions.e2e.spec.ts | 25 ++++---- .../campaignHomeActionButton.unit.spec.tsx | 28 +++++++++ tests/unit/campaignLongPress.unit.spec.tsx | 18 +++++- 11 files changed, 130 insertions(+), 87 deletions(-) diff --git a/.cursor/rules/projects/nucleos-eleitorais.mdc b/.cursor/rules/projects/nucleos-eleitorais.mdc index 22cfb318..6071247c 100644 --- a/.cursor/rules/projects/nucleos-eleitorais.mdc +++ b/.cursor/rules/projects/nucleos-eleitorais.mdc @@ -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.~~ diff --git a/docs/plans/polimento-strip-acoes-inicio.md b/docs/plans/polimento-strip-acoes-inicio.md index d2db18ed..85bb1162 100644 --- a/docs/plans/polimento-strip-acoes-inicio.md +++ b/docs/plans/polimento-strip-acoes-inicio.md @@ -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) diff --git a/docs/roadmap.md b/docs/roadmap.md index 30cddd47..98b3f9a9 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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) diff --git a/src/components/campaign/dashboard/CampaignHomeActionButton.tsx b/src/components/campaign/dashboard/CampaignHomeActionButton.tsx index 6e7e77e5..0d682e9d 100644 --- a/src/components/campaign/dashboard/CampaignHomeActionButton.tsx +++ b/src/components/campaign/dashboard/CampaignHomeActionButton.tsx @@ -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 }) => ( <> - + @@ -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 = ( - + ) @@ -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 + } > diff --git a/src/components/campaign/dashboard/CampaignHomeActionStrip.tsx b/src/components/campaign/dashboard/CampaignHomeActionStrip.tsx index 29491f2c..536b5867 100644 --- a/src/components/campaign/dashboard/CampaignHomeActionStrip.tsx +++ b/src/components/campaign/dashboard/CampaignHomeActionStrip.tsx @@ -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 = ({
    - {children} {actions?.map(({ id, ...button }) => (
  • diff --git a/src/components/campaign/dashboard/CampaignHomeActions.tsx b/src/components/campaign/dashboard/CampaignHomeActions.tsx index 6429b0c6..db70930d 100644 --- a/src/components/campaign/dashboard/CampaignHomeActions.tsx +++ b/src/components/campaign/dashboard/CampaignHomeActions.tsx @@ -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 ( -
    -

    - O que você quer fazer? -

    - -
    - ) + return } diff --git a/src/lib/campaignHomeActions.ts b/src/lib/campaignHomeActions.ts index db29c2c3..17b6b5fb 100644 --- a/src/lib/campaignHomeActions.ts +++ b/src/lib/campaignHomeActions.ts @@ -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)', }, diff --git a/src/lib/campaignLongPress.ts b/src/lib/campaignLongPress.ts index 4feddb57..54a66218 100644 --- a/src/lib/campaignLongPress.ts +++ b/src/lib/campaignLongPress.ts @@ -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 | 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], + ) } diff --git a/tests/e2e/campaignHomeActions.e2e.spec.ts b/tests/e2e/campaignHomeActions.e2e.spec.ts index 3ed122c0..b9fdd171 100644 --- a/tests/e2e/campaignHomeActions.e2e.spec.ts +++ b/tests/e2e/campaignHomeActions.e2e.spec.ts @@ -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 }), diff --git a/tests/unit/campaignHomeActionButton.unit.spec.tsx b/tests/unit/campaignHomeActionButton.unit.spec.tsx index 753fffe8..af55a922 100644 --- a/tests/unit/campaignHomeActionButton.unit.spec.tsx +++ b/tests/unit/campaignHomeActionButton.unit.spec.tsx @@ -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() }) }) diff --git a/tests/unit/campaignLongPress.unit.spec.tsx b/tests/unit/campaignLongPress.unit.spec.tsx index 9cbf7a44..9c59f00d 100644 --- a/tests/unit/campaignLongPress.unit.spec.tsx +++ b/tests/unit/campaignLongPress.unit.spec.tsx @@ -9,13 +9,13 @@ const LongPressHarness = ({ onLongPress: () => void onClick?: () => void }) => { - const handlers = useCampaignLongPress({ + const { pressing, ...handlers } = useCampaignLongPress({ enabled: true, onLongPress, onClick, }) return ( - ) @@ -88,6 +88,20 @@ describe('useCampaignLongPress', () => { expect(onLongPress).not.toHaveBeenCalled() }) + it('sets pressing while the primary pointer is down', () => { + const onLongPress = vi.fn() + render() + + 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()