B78 — Indicar carregamento na navegação do wizard

Reusa CampaignListPendingBoundary no layout autenticado para compartilhar
useTransition entre o header mobile e o conteúdo do passo. Voltar, X, Pular,
seleção de município e tiles de passo passam por CampaignWizardNavLink;
o corpo dima com aria-busy e o header mostra skeleton nos slots de título.

CampaignTransitionAnchor agora retorna a promise do router para manter o
pending honesto até a navegação terminar.

Closes #14
This commit is contained in:
Cursor Agent 2026-07-31 00:24:39 +00:00
parent e04db48742
commit 6c8d7d418c
18 changed files with 328 additions and 98 deletions

View file

@ -12,6 +12,7 @@ import { CampaignMobileTopBar } from '@/components/campaign/shell/CampaignMobile
import { CampaignSidebar } from '@/components/campaign/shell/CampaignSidebar'
import { CampaignSidebarViewportDefault } from '@/components/campaign/shell/CampaignSidebarViewportDefault'
import { CampaignWizardChromeProvider } from '@/components/campaign/shell/CampaignWizardChromeContext'
import { CampaignListPendingBoundary } from '@/components/campaign/shared/CampaignListPending'
import { InstallPwaToast } from '@/components/campaign/shell/InstallPwaToast'
import {
SIDEBAR_COOKIE_NAME,
@ -71,19 +72,21 @@ export default async function CampaignAppLayout({ children }: { children: React.
<CampaignSidebar user={campaignUserShellView(user)} />
<SidebarInset className="h-svh min-h-0 overflow-hidden print:h-auto print:overflow-visible">
<CampaignWizardChromeProvider>
<CampaignMobileTopBar />
<header className="hidden min-h-11 shrink-0 items-center gap-2 border-b border-border px-4 md:flex print:hidden">
<SidebarTrigger />
</header>
<div
data-slot="campaign-content-scroll"
className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-4 md:p-6 print:h-auto print:overflow-visible print:p-0"
>
<TooltipProvider delayDuration={300}>{children}</TooltipProvider>
</div>
<Toaster position="top-center" />
<InstallPwaToast />
{biometricEnrollment ? <BiometricEnrollmentToast {...biometricEnrollment} /> : null}
<CampaignListPendingBoundary>
<CampaignMobileTopBar />
<header className="hidden min-h-11 shrink-0 items-center gap-2 border-b border-border px-4 md:flex print:hidden">
<SidebarTrigger />
</header>
<div
data-slot="campaign-content-scroll"
className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-4 md:p-6 print:h-auto print:overflow-visible print:p-0"
>
<TooltipProvider delayDuration={300}>{children}</TooltipProvider>
</div>
<Toaster position="top-center" />
<InstallPwaToast />
{biometricEnrollment ? <BiometricEnrollmentToast {...biometricEnrollment} /> : null}
</CampaignListPendingBoundary>
</CampaignWizardChromeProvider>
</SidebarInset>
</SidebarProvider>

View file

@ -1,9 +1,10 @@
'use client'
import Link from 'next/link'
import type { ReactNode } from 'react'
import type { MouseEvent, ReactNode } from 'react'
import { MunicipalityPriorityIndicator } from '@/components/campaign/municipality/MunicipalityPriorityIndicator'
import { CampaignWizardNavLink } from '@/components/campaign/shared/CampaignWizardNavLink'
import { HOME_SEARCH_HIT_ROW_WRAPPER_CLASS } from '@/lib/homeSearchUi'
import { cn } from '@/lib/utils'
@ -16,6 +17,7 @@ export const HomeSearchHitRow = ({
trailing,
trailingAction,
showPriority = false,
wizardNavigation = false,
}: {
href: string
primary: string
@ -23,36 +25,58 @@ export const HomeSearchHitRow = ({
trailing?: ReactNode
trailingAction?: ReactNode
showPriority?: boolean
}) => (
/** Wizard route picks participate in the shared layout transition. */
wizardNavigation?: boolean
}) => {
const rowLinkClass = cn(
'flex min-h-11 items-center gap-3 py-2.5 text-left text-foreground',
'outline-none focus-visible:ring-2 focus-visible:ring-ring',
trailingAction ? 'min-w-0 flex-1' : 'w-full',
)
const rowLinkProps = {
href,
onMouseDown: (event: MouseEvent<HTMLAnchorElement>) => {
event.preventDefault()
},
className: rowLinkClass,
}
return (
<div
className={cn(
HOME_SEARCH_HIT_ROW_WRAPPER_CLASS,
trailingAction ? 'flex items-center gap-0' : undefined,
)}
>
<Link
href={href}
onMouseDown={(event) => {
// Combobox/list precedent: RelationChipCell — keep input focused until navigation (B66).
event.preventDefault()
}}
className={cn(
'flex min-h-11 items-center gap-3 py-2.5 text-left text-foreground',
'outline-none focus-visible:ring-2 focus-visible:ring-ring',
trailingAction ? 'min-w-0 flex-1' : 'w-full',
)}
>
<span className={HOME_SEARCH_PRIORITY_ICON_SLOT_CLASS} aria-hidden={!showPriority}>
{showPriority ? <MunicipalityPriorityIndicator /> : null}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate font-medium">{primary}</span>
{secondary ? (
<span className="block truncate text-xs text-muted-foreground">{secondary}</span>
) : null}
</span>
{trailing ? <span className="shrink-0">{trailing}</span> : null}
</Link>
{wizardNavigation ? (
<CampaignWizardNavLink {...rowLinkProps}>
<span className={HOME_SEARCH_PRIORITY_ICON_SLOT_CLASS} aria-hidden={!showPriority}>
{showPriority ? <MunicipalityPriorityIndicator /> : null}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate font-medium">{primary}</span>
{secondary ? (
<span className="block truncate text-xs text-muted-foreground">{secondary}</span>
) : null}
</span>
{trailing ? <span className="shrink-0">{trailing}</span> : null}
</CampaignWizardNavLink>
) : (
<Link {...rowLinkProps}>
<span className={HOME_SEARCH_PRIORITY_ICON_SLOT_CLASS} aria-hidden={!showPriority}>
{showPriority ? <MunicipalityPriorityIndicator /> : null}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate font-medium">{primary}</span>
{secondary ? (
<span className="block truncate text-xs text-muted-foreground">{secondary}</span>
) : null}
</span>
{trailing ? <span className="shrink-0">{trailing}</span> : null}
</Link>
)}
{trailingAction ? <span className="shrink-0">{trailingAction}</span> : null}
</div>
)
)
}

View file

@ -1,13 +1,13 @@
'use client'
import { InfoIcon, PlusIcon } from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useCallback, useState, useTransition } from 'react'
import { toast } from 'sonner'
import { SupportStatusBadge } from '@/components/campaign/leadership/SupportStatusBadge'
import { WizardLeadershipForm } from '@/components/campaign/leadership/WizardLeadershipForm'
import { CampaignWizardNavLink } from '@/components/campaign/shared/CampaignWizardNavLink'
import { CampaignWizardShell } from '@/components/campaign/shared/CampaignWizardShell'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
@ -89,7 +89,7 @@ export const WizardLeadershipStep = ({
const trailingAction = skipConfig ? (
<Button variant="ghost" size="sm" className="min-h-11 px-2 text-sm" asChild>
<Link href={skipConfig.href}>{skipConfig.label}</Link>
<CampaignWizardNavLink href={skipConfig.href}>{skipConfig.label}</CampaignWizardNavLink>
</Button>
) : undefined

View file

@ -1,5 +1,4 @@
import Link from 'next/link'
import { CampaignWizardNavLink } from '@/components/campaign/shared/CampaignWizardNavLink'
import { Button } from '@/components/ui/button'
import type { WizardSignalSkipAction } from '@/lib/wizardSignalUi'
@ -9,6 +8,6 @@ type WizardSignalSkipTrailingProps = {
export const WizardSignalSkipTrailing = ({ skip }: WizardSignalSkipTrailingProps) => (
<Button variant="link" size="sm" className="h-auto px-2 py-1 text-xs" asChild>
<Link href={skip.href}>{skip.label}</Link>
<CampaignWizardNavLink href={skip.href}>{skip.label}</CampaignWizardNavLink>
</Button>
)

View file

@ -1,10 +1,10 @@
'use client'
import { Info } from 'lucide-react'
import Link from 'next/link'
import { useState } from 'react'
import { WizardSignalSkipTrailing } from '@/components/campaign/municipality/WizardSignalSkipTrailing'
import { CampaignWizardNavLink } from '@/components/campaign/shared/CampaignWizardNavLink'
import { CampaignWizardShell } from '@/components/campaign/shared/CampaignWizardShell'
import { Button } from '@/components/ui/button'
import {
@ -61,7 +61,7 @@ export const WizardSignalTypeStep = ({
const Icon = entry.icon
return (
<li key={entry.type} className="relative">
<Link
<CampaignWizardNavLink
href={wizardSignalHref(actionSlug, municipalitySlug, entry.type, entryAction)}
className={cn(
'flex aspect-square w-full flex-col justify-between rounded-lg border border-border bg-transparent p-3 pr-10 text-left',
@ -75,7 +75,7 @@ export const WizardSignalTypeStep = ({
{entry.shortDescription}
</span>
</div>
</Link>
</CampaignWizardNavLink>
<Button
type="button"
variant="ghost"

View file

@ -1,10 +1,10 @@
'use client'
import { Info } from 'lucide-react'
import Link from 'next/link'
import { useState } from 'react'
import { WizardTrendSkipTrailing } from '@/components/campaign/municipality/WizardTrendSkipTrailing'
import { CampaignWizardNavLink } from '@/components/campaign/shared/CampaignWizardNavLink'
import { CampaignWizardShell } from '@/components/campaign/shared/CampaignWizardShell'
import { Button } from '@/components/ui/button'
import {
@ -70,7 +70,7 @@ export const WizardTrendChoiceStep = ({
const Icon = entry.icon
return (
<li key={status} className="relative">
<Link
<CampaignWizardNavLink
href={wizardTrendHref(
actionSlug,
municipalitySlug,
@ -91,7 +91,7 @@ export const WizardTrendChoiceStep = ({
{entry.changeDescription}
</span>
</div>
</Link>
</CampaignWizardNavLink>
<Button
type="button"
variant="ghost"

View file

@ -1,5 +1,4 @@
import Link from 'next/link'
import { CampaignWizardNavLink } from '@/components/campaign/shared/CampaignWizardNavLink'
import { Button } from '@/components/ui/button'
import type { WizardTrendSkipAction } from '@/lib/politicalTrendWizardUi'
@ -9,6 +8,6 @@ type WizardTrendSkipTrailingProps = {
export const WizardTrendSkipTrailing = ({ skip }: WizardTrendSkipTrailingProps) => (
<Button variant="link" size="sm" className="h-auto px-2 py-1 text-xs" asChild>
<Link href={skip.href}>{skip.label}</Link>
<CampaignWizardNavLink href={skip.href}>{skip.label}</CampaignWizardNavLink>
</Button>
)

View file

@ -93,8 +93,7 @@ export const CampaignTransitionAnchor = forwardRef<
event.preventDefault()
startTransition(() => {
onNavigate?.()
if (replace) router.replace(href, { scroll })
else router.push(href, { scroll })
return replace ? router.replace(href, { scroll }) : router.push(href, { scroll })
})
}}
>
@ -107,9 +106,11 @@ export const CampaignTransitionAnchor = forwardRef<
export const CampaignListResults = ({
children,
className,
pendingMessage = 'Atualizando resultados…',
}: {
children: ReactNode
className?: string
pendingMessage?: string
}) => {
const shared = useContext(CampaignListPendingContext)
const isPending = shared?.isPending ?? false
@ -124,7 +125,7 @@ export const CampaignListResults = ({
aria-busy={isPending}
>
<p className="sr-only" aria-live="polite">
{isPending ? 'Atualizando resultados…' : ''}
{isPending ? pendingMessage : ''}
</p>
{children}
</div>

View file

@ -0,0 +1,33 @@
'use client'
import type { ComponentPropsWithoutRef } from 'react'
import {
CampaignTransitionAnchor,
useCampaignListTransition,
} from '@/components/campaign/shared/CampaignListPending'
import { cn } from '@/lib/utils'
/**
* Wizard route navigation with shared list pending: drives the layout boundary
* and locks sibling triggers while the RSC payload loads.
*/
export const CampaignWizardNavLink = ({
href,
className,
children,
...anchorProps
}: Omit<ComponentPropsWithoutRef<'a'>, 'href'> & { href: string }) => {
const { isPending } = useCampaignListTransition()
return (
<CampaignTransitionAnchor
href={href}
className={cn(isPending && 'pointer-events-none', className)}
aria-busy={isPending || undefined}
{...anchorProps}
>
{children}
</CampaignTransitionAnchor>
)
}

View file

@ -7,7 +7,9 @@ import {
useSetCampaignWizardChrome,
type CampaignWizardChromeSkip,
} from '@/components/campaign/shell/CampaignWizardChromeContext'
import { CampaignListResults } from '@/components/campaign/shared/CampaignListPending'
import { CAMPAIGN_HOME } from '@/lib/campaignPaths'
import { WIZARD_STEP_PENDING_MESSAGE } from '@/lib/campaignWizardCopy'
import { cn } from '@/lib/utils'
export type CampaignWizardShellProps = {
@ -64,24 +66,32 @@ export const CampaignWizardShell = ({
<main
aria-labelledby={titleId}
className={cn(
'flex flex-1 flex-col gap-6 py-6 md:justify-start',
'flex flex-1 flex-col py-6 md:justify-start',
contentAlign === 'end' ? 'justify-end md:justify-start' : 'justify-start',
)}
>
{municipalityLabel ? (
<p className="hidden max-w-prose truncate text-sm text-muted-foreground md:block">
{municipalityLabel}
</p>
) : null}
<h1
ref={titleRef}
id={titleId}
tabIndex={-1}
className="text-xl font-semibold tracking-tight outline-none md:text-2xl"
<CampaignListResults
pendingMessage={WIZARD_STEP_PENDING_MESSAGE}
className={cn(
'flex flex-1 flex-col gap-6',
contentAlign === 'end' ? 'justify-end md:justify-start' : 'justify-start',
)}
>
{stepTitle}
</h1>
{children}
{municipalityLabel ? (
<p className="hidden max-w-prose truncate text-sm text-muted-foreground md:block">
{municipalityLabel}
</p>
) : null}
<h1
ref={titleRef}
id={titleId}
tabIndex={-1}
className="text-xl font-semibold tracking-tight outline-none md:text-2xl"
>
{stepTitle}
</h1>
{children}
</CampaignListResults>
</main>
</div>
)

View file

@ -1,6 +1,5 @@
'use client'
import Link from 'next/link'
import { useState, useTransition } from 'react'
import {
@ -8,6 +7,7 @@ import {
MUNICIPALITY_EXPECTED_VOTES_SAVE_ERROR_MESSAGE,
type MunicipalityListExpectedVotesResponse,
} from '@/app/(campaign)/campanha/(app)/municipios/expected-votes/types'
import { CampaignWizardNavLink } from '@/components/campaign/shared/CampaignWizardNavLink'
import { CampaignWizardShell } from '@/components/campaign/shared/CampaignWizardShell'
import { VoteEstimateScenarioInputs } from '@/components/campaign/votePledge/VoteEstimateScenarioInputs'
import { Alert, AlertDescription } from '@/components/ui/Alert'
@ -117,7 +117,7 @@ export const WizardExpectedVotesStep = ({
<p className="text-sm text-muted-foreground">{WIZARD_VOTES_SAVED_MESSAGE}</p>
<p className="text-sm text-muted-foreground">{wizardNextStepPlaceholder(actionSlug)}</p>
<Button asChild className="min-h-11 w-full sm:w-auto">
<Link href={CAMPAIGN_HOME}>Voltar ao Início</Link>
<CampaignWizardNavLink href={CAMPAIGN_HOME}>Voltar ao Início</CampaignWizardNavLink>
</Button>
</div>
</CampaignWizardShell>

View file

@ -127,6 +127,7 @@ export const WizardMunicipalitySearchStep = ({
primary={hit.name}
secondary={hit.region}
showPriority={hit.priority === 'alta'}
wizardNavigation
trailing={
<HomeSearchMunicipalityVoteTrailing position={hit.votePosition2022} />
}

View file

@ -1,11 +1,13 @@
'use client'
import { ArrowLeft, X } from 'lucide-react'
import Link from 'next/link'
import { useCampaignWizardChrome } from '@/components/campaign/shell/CampaignWizardChromeContext'
import { useCampaignListTransition } from '@/components/campaign/shared/CampaignListPending'
import { CampaignWizardNavLink } from '@/components/campaign/shared/CampaignWizardNavLink'
import { Button } from '@/components/ui/button'
import { SidebarTrigger } from '@/components/ui/Sidebar'
import { Skeleton } from '@/components/ui/skeleton'
import {
WIZARD_APP_TOP_BAR_ARIA_LABEL,
WIZARD_DISMISS_ARIA_LABEL,
@ -14,8 +16,21 @@ import {
} from '@/lib/campaignWizardCopy'
import { cn } from '@/lib/utils'
const wizardNavButtonClass =
'min-h-11 shrink-0 gap-1 px-2 text-primary-foreground hover:bg-primary-foreground/10 hover:text-primary-foreground'
const wizardDismissButtonClass =
'size-11 shrink-0 text-primary-foreground hover:bg-primary-foreground/10 hover:text-primary-foreground'
const wizardSkipButtonClass =
'min-h-11 max-w-[9rem] px-2 text-primary-foreground hover:bg-primary-foreground/10 hover:text-primary-foreground'
const wizardTitleSkeletonClass =
'mx-auto motion-reduce:animate-none bg-primary-foreground/20'
export const CampaignMobileTopBar = () => {
const chrome = useCampaignWizardChrome()
const { isPending } = useCampaignListTransition()
if (chrome) {
return (
@ -33,28 +48,42 @@ export const CampaignMobileTopBar = () => {
<Button
variant="ghost"
size="sm"
className="min-h-11 shrink-0 gap-1 px-2 text-primary-foreground hover:bg-primary-foreground/10 hover:text-primary-foreground"
className={wizardNavButtonClass}
asChild
disabled={isPending}
>
<Link href={chrome.previousHref}>
<CampaignWizardNavLink href={chrome.previousHref}>
<ArrowLeft className="size-4 shrink-0" aria-hidden />
Voltar
</Link>
</CampaignWizardNavLink>
</Button>
) : (
<div className="size-11 shrink-0" aria-hidden />
)}
<div className="min-w-0 flex-1 px-1 text-center leading-tight">
<span className="block truncate text-sm font-semibold">{chrome.flowTitle}</span>
{chrome.municipalityLabel ? (
<span
className="block truncate text-xs text-primary-foreground/80"
aria-label={wizardMunicipalityChromeAriaLabel(chrome.municipalityLabel)}
>
{chrome.municipalityLabel}
</span>
) : null}
{isPending ? (
<>
<Skeleton className={cn(wizardTitleSkeletonClass, 'h-4 w-32 max-w-full')} />
{chrome.municipalityLabel ? (
<Skeleton
className={cn(wizardTitleSkeletonClass, 'mt-1 h-3 w-24 max-w-full')}
/>
) : null}
</>
) : (
<>
<span className="block truncate text-sm font-semibold">{chrome.flowTitle}</span>
{chrome.municipalityLabel ? (
<span
className="block truncate text-xs text-primary-foreground/80"
aria-label={wizardMunicipalityChromeAriaLabel(chrome.municipalityLabel)}
>
{chrome.municipalityLabel}
</span>
) : null}
</>
)}
</div>
</div>
@ -63,23 +92,28 @@ export const CampaignMobileTopBar = () => {
<Button
variant="ghost"
size="sm"
className="min-h-11 max-w-[9rem] px-2 text-primary-foreground hover:bg-primary-foreground/10 hover:text-primary-foreground"
className={wizardSkipButtonClass}
asChild
disabled={isPending}
>
<Link href={chrome.skip.href} title={chrome.skip.label}>
<CampaignWizardNavLink href={chrome.skip.href} title={chrome.skip.label}>
{chrome.skip.label}
</Link>
</CampaignWizardNavLink>
</Button>
) : (
<Button
variant="ghost"
size="icon"
className="size-11 shrink-0 text-primary-foreground hover:bg-primary-foreground/10 hover:text-primary-foreground"
className={wizardDismissButtonClass}
asChild
disabled={isPending}
>
<Link href={chrome.dismissHref} aria-label={WIZARD_DISMISS_ARIA_LABEL}>
<CampaignWizardNavLink
href={chrome.dismissHref}
aria-label={WIZARD_DISMISS_ARIA_LABEL}
>
<X className="size-5" aria-hidden />
</Link>
</CampaignWizardNavLink>
</Button>
)}
</div>

View file

@ -8,6 +8,8 @@ export const WIZARD_MUNICIPALITY_STEP_TITLE = 'Em qual município?' as const
export const WIZARD_DISMISS_ARIA_LABEL = 'Sair da ação' as const
export const WIZARD_STEP_PENDING_MESSAGE = 'Carregando passo…' as const
export const WIZARD_APP_TOP_BAR_ARIA_LABEL = 'Campanha Jorge Solla' as const
export const wizardMunicipalityChromeAriaLabel = (municipalityLabel: string): string =>

View file

@ -1,6 +1,13 @@
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: vi.fn(),
replace: vi.fn(),
}),
}))
import { CampaignMobileTopBar } from '@/components/campaign/shell/CampaignMobileTopBar'
import {
CampaignWizardChromeProvider,

View file

@ -1,6 +1,13 @@
import { cleanup, render, screen, within } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: vi.fn(),
replace: vi.fn(),
}),
}))
import { CampaignWizardShell } from '@/components/campaign/shared/CampaignWizardShell'
import { CampaignMobileTopBar } from '@/components/campaign/shell/CampaignMobileTopBar'
import { CampaignWizardChromeProvider } from '@/components/campaign/shell/CampaignWizardChromeContext'
@ -125,7 +132,7 @@ describe('CampaignWizardShell', () => {
children: null,
})
const caption = container.querySelector('main p')
const caption = container.querySelector('main p.md\\:block')
expect(caption?.textContent).toBe('Cairu')
expect(caption?.className).toMatch(/md:block/)
})

View file

@ -5,12 +5,11 @@ import { WizardExpectedVotesStep } from '@/components/campaign/shared/WizardExpe
import { CampaignWizardChromeProvider } from '@/components/campaign/shell/CampaignWizardChromeContext'
import { postCampaignJson } from '@/lib/campaignJsonRequest'
vi.mock('next/link', () => ({
default: ({ children, href, ...props }: { children: React.ReactNode; href: string }) => (
<a href={href} {...props}>
{children}
</a>
),
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: vi.fn(),
replace: vi.fn(),
}),
}))
vi.mock('@/lib/campaignJsonRequest', () => ({

View file

@ -0,0 +1,111 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { act } from 'react'
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
const routerState = vi.hoisted(() => ({
push: vi.fn(),
resolvePush: null as (() => void) | null,
}))
vi.mock('next/navigation', async (importActual) => ({
...(await importActual()),
useRouter: () => ({
push: (...args: unknown[]) => routerState.push(...args),
replace: vi.fn(),
}),
}))
import { CampaignMobileTopBar } from '@/components/campaign/shell/CampaignMobileTopBar'
import { CampaignWizardChromeProvider } from '@/components/campaign/shell/CampaignWizardChromeContext'
import { CampaignListPendingBoundary } from '@/components/campaign/shared/CampaignListPending'
import { CampaignWizardShell } from '@/components/campaign/shared/CampaignWizardShell'
import { SidebarProvider } from '@/components/ui/Sidebar'
import { WIZARD_STEP_PENDING_MESSAGE } from '@/lib/campaignWizardCopy'
import { stubMatchMedia } from '../helpers/matchMedia'
const mountWizardNavigation = () =>
render(
<SidebarProvider>
<CampaignWizardChromeProvider>
<CampaignListPendingBoundary>
<CampaignMobileTopBar />
<CampaignWizardShell
flowTitle="Ajustar votos"
stepTitle="Quantos votos?"
isEntryStep={false}
previousHref="/campanha/acoes/atualizar-votos"
dismissHref="/campanha"
municipalityLabel="Cairu"
>
Corpo do passo
</CampaignWizardShell>
</CampaignListPendingBoundary>
</CampaignWizardChromeProvider>
</SidebarProvider>,
)
const reactActEnvironment = globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean
}
describe('wizard navigation pending', () => {
beforeAll(() => {
reactActEnvironment.IS_REACT_ACT_ENVIRONMENT = true
stubMatchMedia()
})
afterAll(() => {
delete reactActEnvironment.IS_REACT_ACT_ENVIRONMENT
})
afterEach(() => {
cleanup()
routerState.push.mockReset()
routerState.resolvePush = null
vi.unstubAllGlobals()
})
it('dims step content and locks chrome while Voltar navigation is in flight', async () => {
routerState.push.mockImplementation(
() =>
new Promise<void>((resolve) => {
routerState.resolvePush = resolve
}),
)
mountWizardNavigation()
const stepBody = screen.getByRole('heading', { level: 1, name: 'Quantos votos?' }).parentElement
expect(stepBody).not.toBeNull()
expect(stepBody!.getAttribute('aria-busy')).not.toBe('true')
expect(stepBody!.getAttribute('data-pending')).toBeNull()
const backLink = screen.getByRole('link', { name: /Voltar/ })
await act(async () => {
fireEvent.click(backLink)
})
expect(routerState.push).toHaveBeenCalledWith('/campanha/acoes/atualizar-votos', {
scroll: true,
})
await waitFor(() => {
expect(stepBody!.getAttribute('aria-busy')).toBe('true')
expect(stepBody!.getAttribute('data-pending')).toBe('true')
})
expect(screen.getByText(WIZARD_STEP_PENDING_MESSAGE)).toBeTruthy()
expect(backLink.getAttribute('aria-busy')).toBe('true')
await waitFor(() => {
expect(document.querySelector('[data-slot="skeleton"]')).toBeTruthy()
})
await act(async () => {
routerState.resolvePush?.()
})
expect(stepBody!.getAttribute('aria-busy')).not.toBe('true')
expect(stepBody!.getAttribute('data-pending')).toBeNull()
expect(document.querySelector('[data-slot="skeleton"]')).toBeNull()
})
})