diff --git a/src/app/(campaign)/campanha/(app)/layout.tsx b/src/app/(campaign)/campanha/(app)/layout.tsx index d9270482..84e9dd65 100644 --- a/src/app/(campaign)/campanha/(app)/layout.tsx +++ b/src/app/(campaign)/campanha/(app)/layout.tsx @@ -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. - -
- -
-
- {children} -
- - - {biometricEnrollment ? : null} + + +
+ +
+
+ {children} +
+ + + {biometricEnrollment ? : null} +
diff --git a/src/components/campaign/dashboard/HomeSearchHitRow.tsx b/src/components/campaign/dashboard/HomeSearchHitRow.tsx index 4e41d7ee..b3f24df2 100644 --- a/src/components/campaign/dashboard/HomeSearchHitRow.tsx +++ b/src/components/campaign/dashboard/HomeSearchHitRow.tsx @@ -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) => { + event.preventDefault() + }, + className: rowLinkClass, + } + + return (
- { - // 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', - )} - > - - {showPriority ? : null} - - - {primary} - {secondary ? ( - {secondary} - ) : null} - - {trailing ? {trailing} : null} - + {wizardNavigation ? ( + + + {showPriority ? : null} + + + {primary} + {secondary ? ( + {secondary} + ) : null} + + {trailing ? {trailing} : null} + + ) : ( + + + {showPriority ? : null} + + + {primary} + {secondary ? ( + {secondary} + ) : null} + + {trailing ? {trailing} : null} + + )} {trailingAction ? {trailingAction} : null}
-) + ) +} diff --git a/src/components/campaign/leadership/WizardLeadershipStep.tsx b/src/components/campaign/leadership/WizardLeadershipStep.tsx index c350c722..f2ec5dc4 100644 --- a/src/components/campaign/leadership/WizardLeadershipStep.tsx +++ b/src/components/campaign/leadership/WizardLeadershipStep.tsx @@ -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 ? ( ) : undefined diff --git a/src/components/campaign/municipality/WizardSignalSkipTrailing.tsx b/src/components/campaign/municipality/WizardSignalSkipTrailing.tsx index 6752058f..92b9d9c5 100644 --- a/src/components/campaign/municipality/WizardSignalSkipTrailing.tsx +++ b/src/components/campaign/municipality/WizardSignalSkipTrailing.tsx @@ -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) => ( ) diff --git a/src/components/campaign/municipality/WizardSignalTypeStep.tsx b/src/components/campaign/municipality/WizardSignalTypeStep.tsx index a1272c29..258acafa 100644 --- a/src/components/campaign/municipality/WizardSignalTypeStep.tsx +++ b/src/components/campaign/municipality/WizardSignalTypeStep.tsx @@ -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 (
  • - - + ) diff --git a/src/components/campaign/shared/CampaignListPending.tsx b/src/components/campaign/shared/CampaignListPending.tsx index 74c9d027..369df5e2 100644 --- a/src/components/campaign/shared/CampaignListPending.tsx +++ b/src/components/campaign/shared/CampaignListPending.tsx @@ -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} >

    - {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() + }) +})