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:
parent
e04db48742
commit
6c8d7d418c
18 changed files with 328 additions and 98 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
33
src/components/campaign/shared/CampaignWizardNavLink.tsx
Normal file
33
src/components/campaign/shared/CampaignWizardNavLink.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -127,6 +127,7 @@ export const WizardMunicipalitySearchStep = ({
|
|||
primary={hit.name}
|
||||
secondary={hit.region}
|
||||
showPriority={hit.priority === 'alta'}
|
||||
wizardNavigation
|
||||
trailing={
|
||||
<HomeSearchMunicipalityVoteTrailing position={hit.votePosition2022} />
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 =>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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/)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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', () => ({
|
||||
|
|
|
|||
111
tests/unit/wizardNavigationPending.unit.spec.tsx
Normal file
111
tests/unit/wizardNavigationPending.unit.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue