- {isPending ? 'Atualizando resultados…' : ''}
+ {isPending ? pendingMessage : ''}
{children}
diff --git a/src/components/campaign/shared/CampaignWizardNavLink.tsx b/src/components/campaign/shared/CampaignWizardNavLink.tsx
new file mode 100644
index 00000000..10443ab9
--- /dev/null
+++ b/src/components/campaign/shared/CampaignWizardNavLink.tsx
@@ -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, 'href'> & { href: string }) => {
+ const { isPending } = useCampaignListTransition()
+
+ return (
+
+ {children}
+
+ )
+}
diff --git a/src/components/campaign/shared/CampaignWizardShell.tsx b/src/components/campaign/shared/CampaignWizardShell.tsx
index 7ba38df2..0e7c1e6a 100644
--- a/src/components/campaign/shared/CampaignWizardShell.tsx
+++ b/src/components/campaign/shared/CampaignWizardShell.tsx
@@ -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 = ({
- {municipalityLabel ? (
-
- {municipalityLabel}
-
- ) : null}
-
- {stepTitle}
-
- {children}
+ {municipalityLabel ? (
+
+ {municipalityLabel}
+
+ ) : null}
+
+ {stepTitle}
+
+ {children}
+
)
diff --git a/src/components/campaign/shared/WizardExpectedVotesStep.tsx b/src/components/campaign/shared/WizardExpectedVotesStep.tsx
index 165da108..3f20254c 100644
--- a/src/components/campaign/shared/WizardExpectedVotesStep.tsx
+++ b/src/components/campaign/shared/WizardExpectedVotesStep.tsx
@@ -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 = ({
{WIZARD_VOTES_SAVED_MESSAGE}
{wizardNextStepPlaceholder(actionSlug)}
diff --git a/src/components/campaign/shared/WizardMunicipalitySearchStep.tsx b/src/components/campaign/shared/WizardMunicipalitySearchStep.tsx
index 73c158aa..b86c5034 100644
--- a/src/components/campaign/shared/WizardMunicipalitySearchStep.tsx
+++ b/src/components/campaign/shared/WizardMunicipalitySearchStep.tsx
@@ -127,6 +127,7 @@ export const WizardMunicipalitySearchStep = ({
primary={hit.name}
secondary={hit.region}
showPriority={hit.priority === 'alta'}
+ wizardNavigation
trailing={
}
diff --git a/src/components/campaign/shell/CampaignMobileTopBar.tsx b/src/components/campaign/shell/CampaignMobileTopBar.tsx
index 2fd481fb..5b76c22f 100644
--- a/src/components/campaign/shell/CampaignMobileTopBar.tsx
+++ b/src/components/campaign/shell/CampaignMobileTopBar.tsx
@@ -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 = () => {
) : (
)}
- {chrome.flowTitle}
- {chrome.municipalityLabel ? (
-
- {chrome.municipalityLabel}
-
- ) : null}
+ {isPending ? (
+ <>
+
+ {chrome.municipalityLabel ? (
+
+ ) : null}
+ >
+ ) : (
+ <>
+ {chrome.flowTitle}
+ {chrome.municipalityLabel ? (
+
+ {chrome.municipalityLabel}
+
+ ) : null}
+ >
+ )}
@@ -63,23 +92,28 @@ export const CampaignMobileTopBar = () => {
) : (
)}
diff --git a/src/lib/campaignWizardCopy.ts b/src/lib/campaignWizardCopy.ts
index 90640b39..580caf74 100644
--- a/src/lib/campaignWizardCopy.ts
+++ b/src/lib/campaignWizardCopy.ts
@@ -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 =>
diff --git a/tests/unit/campaignMobileTopBar.unit.spec.tsx b/tests/unit/campaignMobileTopBar.unit.spec.tsx
index d6805d81..dbfab634 100644
--- a/tests/unit/campaignMobileTopBar.unit.spec.tsx
+++ b/tests/unit/campaignMobileTopBar.unit.spec.tsx
@@ -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,
diff --git a/tests/unit/campaignWizardShell.unit.spec.tsx b/tests/unit/campaignWizardShell.unit.spec.tsx
index b5fd91cf..2699915e 100644
--- a/tests/unit/campaignWizardShell.unit.spec.tsx
+++ b/tests/unit/campaignWizardShell.unit.spec.tsx
@@ -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/)
})
diff --git a/tests/unit/wizardExpectedVotesStep.unit.spec.tsx b/tests/unit/wizardExpectedVotesStep.unit.spec.tsx
index 7d1a1a9c..687fa347 100644
--- a/tests/unit/wizardExpectedVotesStep.unit.spec.tsx
+++ b/tests/unit/wizardExpectedVotesStep.unit.spec.tsx
@@ -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 }) => (
-
- {children}
-
- ),
+vi.mock('next/navigation', () => ({
+ useRouter: () => ({
+ push: vi.fn(),
+ replace: vi.fn(),
+ }),
}))
vi.mock('@/lib/campaignJsonRequest', () => ({
diff --git a/tests/unit/wizardNavigationPending.unit.spec.tsx b/tests/unit/wizardNavigationPending.unit.spec.tsx
new file mode 100644
index 00000000..82454220
--- /dev/null
+++ b/tests/unit/wizardNavigationPending.unit.spec.tsx
@@ -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(
+
+
+
+
+
+ Corpo do passo
+
+
+
+ ,
+ )
+
+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((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()
+ })
+})