Merge branch 'cursor/32c7cf68'
This commit is contained in:
commit
7383eefafe
9 changed files with 596 additions and 6 deletions
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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 `<li>`.** 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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
164
src/components/campaign/dashboard/CampaignHomeActionButton.tsx
Normal file
164
src/components/campaign/dashboard/CampaignHomeActionButton.tsx
Normal 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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
36
src/lib/campaignCoarsePointer.ts
Normal file
36
src/lib/campaignCoarsePointer.ts
Normal 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)
|
||||
95
src/lib/campaignLongPress.ts
Normal file
95
src/lib/campaignLongPress.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
140
tests/unit/campaignHomeActionButton.unit.spec.tsx
Normal file
140
tests/unit/campaignHomeActionButton.unit.spec.tsx
Normal 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 { 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
|
||||
}) => (
|
||||
<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()
|
||||
})
|
||||
})
|
||||
103
tests/unit/campaignLongPress.unit.spec.tsx
Normal file
103
tests/unit/campaignLongPress.unit.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue