Merge pull request #145 from fsolla/cursor/pool-i142-0f38ef32-58e4

B107 — Wizard município — sem título + busca sticky + skeleton
This commit is contained in:
Francisco Solla 2026-08-01 15:57:38 -03:00 committed by GitHub
commit 97ae71b0fe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 109 additions and 34 deletions

View file

@ -0,0 +1,25 @@
import { Skeleton } from '@/components/ui/skeleton'
import { HOME_SEARCH_HIT_ROW_WRAPPER_CLASS } from '@/lib/homeSearchUi'
const HOME_SEARCH_HIT_ROW_SKELETON_PRIORITY_CLASS = 'h-4 w-4 shrink-0 rounded-sm'
const HOME_SEARCH_HIT_ROW_SKELETON_PRIMARY_CLASS = 'h-4 w-40 max-w-full'
const HOME_SEARCH_HIT_ROW_SKELETON_SECONDARY_CLASS = 'h-3 w-28 max-w-full'
export const HomeSearchHitRowsSkeleton = ({ count = 3 }: { count?: number }) => (
<ul className="m-0 list-none p-0" aria-hidden="true">
{Array.from({ length: count }, (_, index) => (
<li key={index}>
<div className={HOME_SEARCH_HIT_ROW_WRAPPER_CLASS}>
<div className="flex min-h-11 items-center gap-3 py-2.5">
<Skeleton className={HOME_SEARCH_HIT_ROW_SKELETON_PRIORITY_CLASS} />
<div className="min-w-0 flex-1 space-y-1">
<Skeleton className={HOME_SEARCH_HIT_ROW_SKELETON_PRIMARY_CLASS} />
<Skeleton className={HOME_SEARCH_HIT_ROW_SKELETON_SECONDARY_CLASS} />
</div>
<Skeleton className="h-4 w-8 shrink-0 tabular-nums" />
</div>
</div>
</li>
))}
</ul>
)

View file

@ -9,12 +9,12 @@ import {
type CampaignWizardChromeSkip,
} from '@/components/campaign/shell/CampaignWizardChromeContext'
import { CAMPAIGN_HOME } from '@/lib/campaignPaths'
import { WIZARD_STEP_PENDING_MESSAGE } from '@/lib/campaignWizardCopy'
import { WIZARD_STEP_PENDING_MESSAGE, wizardFlowChromeAriaLabel } from '@/lib/campaignWizardCopy'
import { cn } from '@/lib/utils'
export type CampaignWizardShellProps = {
flowTitle: string
stepTitle: string
stepTitle?: string | null
isEntryStep: boolean
previousHref: string
dismissHref?: string
@ -37,6 +37,7 @@ export const CampaignWizardShell = ({
contentAlign = 'start',
children,
}: CampaignWizardShellProps) => {
const hasStepTitle = stepTitle != null && stepTitle !== ''
const titleRef = useRef<HTMLHeadingElement>(null)
const titleId = useId()
@ -56,24 +57,28 @@ export const CampaignWizardShell = ({
useSetCampaignWizardChrome(chrome)
useEffect(() => {
if (!hasStepTitle) return
titleRef.current?.focus()
}, [stepTitle])
}, [hasStepTitle, stepTitle])
return (
<div className="flex min-h-full w-full flex-col">
{trailingAction ? <div className="hidden justify-end md:flex">{trailingAction}</div> : null}
<main
aria-labelledby={titleId}
aria-labelledby={hasStepTitle ? titleId : undefined}
aria-label={hasStepTitle ? undefined : wizardFlowChromeAriaLabel(flowTitle)}
className={cn(
'flex flex-1 flex-col pt-3 pb-6 md:justify-start md:py-6',
'flex flex-1 flex-col pb-6 md:justify-start md:py-6',
hasStepTitle ? 'pt-3' : 'pt-2',
contentAlign === 'end' ? 'justify-end md:justify-start' : 'justify-start',
)}
>
<CampaignListResults
pendingMessage={WIZARD_STEP_PENDING_MESSAGE}
className={cn(
'flex flex-1 flex-col gap-6',
'flex flex-1 flex-col',
hasStepTitle ? 'gap-6' : 'gap-0',
contentAlign === 'end' ? 'justify-end md:justify-start' : 'justify-start',
)}
>
@ -82,14 +87,16 @@ export const CampaignWizardShell = ({
{municipalityLabel}
</p>
) : null}
<h1
ref={titleRef}
id={titleId}
tabIndex={-1}
className="text-xl font-semibold tracking-tight outline-none md:text-2xl"
>
{stepTitle}
</h1>
{hasStepTitle ? (
<h1
ref={titleRef}
id={titleId}
tabIndex={-1}
className="text-xl font-semibold tracking-tight outline-none md:text-2xl"
>
{stepTitle}
</h1>
) : null}
{children}
</CampaignListResults>
</main>

View file

@ -3,6 +3,7 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { HomeSearchHitRow } from '@/components/campaign/dashboard/HomeSearchHitRow'
import { HomeSearchHitRowsSkeleton } from '@/components/campaign/dashboard/HomeSearchHitRowsSkeleton'
import { HomeSearchMunicipalityVoteTrailing } from '@/components/campaign/dashboard/HomeSearchMunicipalityVoteTrailing'
import { useHomeSearchQuery } from '@/components/campaign/dashboard/useHomeSearchQuery'
import { CampaignSearchInput } from '@/components/campaign/shared/CampaignSearchInput'
@ -170,30 +171,35 @@ export const WizardMunicipalitySearchStep = ({
results.mode === 'search' &&
results.municipalities.length === 0
const showSkeleton =
resultsBusy && displayRows.length === 0 && results.status !== 'error' && !showEmpty
return (
<CampaignWizardShell
flowTitle={wizardFlowTitleForSlug(actionSlug)}
stepTitle={WIZARD_MUNICIPALITY_STEP_TITLE}
stepTitle={null}
isEntryStep
previousHref={previousHref}
dismissHref={previousHref}
>
<div className="flex flex-col gap-2">
<CampaignSearchInput
id={WIZARD_MUNICIPALITY_SEARCH_INPUT_ID}
label={WIZARD_MUNICIPALITY_SEARCH_LABEL}
placeholder={WIZARD_MUNICIPALITY_SEARCH_PLACEHOLDER}
value={query.raw}
onChange={(event) => setRaw(event.target.value)}
autoComplete="off"
enterKeyHint="search"
aria-busy={resultsBusy || undefined}
maxLength={HOME_SEARCH_QUERY_MAX_LENGTH}
/>
<div className="flex flex-col">
<div className="sticky top-0 z-10 -mx-4 bg-background px-4 pb-2 md:-mx-6 md:px-6">
<CampaignSearchInput
id={WIZARD_MUNICIPALITY_SEARCH_INPUT_ID}
label={WIZARD_MUNICIPALITY_SEARCH_LABEL}
placeholder={WIZARD_MUNICIPALITY_SEARCH_PLACEHOLDER}
value={query.raw}
onChange={(event) => setRaw(event.target.value)}
autoComplete="off"
enterKeyHint="search"
aria-busy={resultsBusy || undefined}
maxLength={HOME_SEARCH_QUERY_MAX_LENGTH}
/>
</div>
<div
role="region"
aria-live="polite"
aria-label="Resultados da busca"
aria-label={WIZARD_MUNICIPALITY_STEP_TITLE}
aria-busy={resultsBusy || undefined}
className="min-w-0"
>
@ -202,6 +208,7 @@ export const WizardMunicipalitySearchStep = ({
{results.message}
</p>
) : null}
{showSkeleton ? <HomeSearchHitRowsSkeleton /> : null}
{results.status === 'success' && displayRows.length > 0 ? (
<ul className={HOME_SEARCH_GROUP_LIST_CLASS}>
{displayRows.map(({ hit, continuityReason }) => (

View file

@ -122,7 +122,8 @@ test.describe('Início — catálogo de ações (B45)', () => {
await page.getByRole('link', { name: 'Ajustar votos', exact: true }).click()
await page.waitForURL(/\/campanha\/acoes\/atualizar-votos/)
await expect(page.getByRole('heading', { name: WIZARD_MUNICIPALITY_STEP_TITLE })).toBeVisible()
await expect(page.getByLabel('Buscar município')).toBeVisible()
await expect(page.getByRole('region', { name: WIZARD_MUNICIPALITY_STEP_TITLE })).toBeVisible()
await page.goto('/campanha')
await page.getByRole('link', { name: 'Ver esquecidos', exact: true }).click()
@ -167,12 +168,13 @@ test.describe('Wizard — busca município (B60)', () => {
await campaign.login(page, coordinator.email!, coordinator.password)
await page.goto('/campanha/acoes/atualizar-votos')
await expect(page.getByRole('heading', { name: WIZARD_MUNICIPALITY_STEP_TITLE })).toBeVisible()
await expect(page.getByLabel('Buscar município')).toBeVisible()
await expect(page.getByRole('region', { name: WIZARD_MUNICIPALITY_STEP_TITLE })).toBeVisible()
const search = page.getByLabel('Buscar município')
await search.fill(municipality.name)
const results = page.getByRole('region', { name: 'Resultados da busca' })
const results = page.getByRole('region', { name: WIZARD_MUNICIPALITY_STEP_TITLE })
const hit = results.locator(`a[href*="municipio=${municipality.slug}"]`)
await expect(hit).toBeVisible({ timeout: 15000 })
await hit.click()

View file

@ -19,7 +19,9 @@ test.describe('Wizard — header mobile (B75)', () => {
const topBar = page.locator('[data-slot="campaign-mobile-top-bar"][data-mode="wizard"]')
await expect(topBar).toBeVisible()
await expect(topBar.getByText('Ajustar votos', { exact: true })).toBeVisible()
await expect(page.getByRole('heading', { name: WIZARD_MUNICIPALITY_STEP_TITLE })).toBeVisible()
await expect(page.getByRole('heading', { name: WIZARD_MUNICIPALITY_STEP_TITLE })).toHaveCount(0)
await expect(page.getByLabel('Buscar município')).toBeVisible()
await expect(page.getByRole('region', { name: WIZARD_MUNICIPALITY_STEP_TITLE })).toBeVisible()
await expect(page.getByRole('link', { name: 'Sair da ação' })).toBeVisible()
await expect(page.getByRole('link', { name: /Voltar/ })).toHaveCount(0)
@ -55,6 +57,7 @@ test.describe('Wizard — header mobile (B75)', () => {
await page.getByRole('link', { name: /Voltar/ }).click()
await page.waitForURL(/\/campanha\/acoes\/atualizar-votos\/?$/)
await expect(page.getByRole('heading', { name: WIZARD_MUNICIPALITY_STEP_TITLE })).toBeVisible()
await expect(page.getByRole('heading', { name: WIZARD_MUNICIPALITY_STEP_TITLE })).toHaveCount(0)
await expect(page.getByLabel('Buscar município')).toBeVisible()
})
})

View file

@ -12,7 +12,7 @@ import { CampaignWizardShell } from '@/components/campaign/shared/CampaignWizard
import { CampaignMobileTopBar } from '@/components/campaign/shell/CampaignMobileTopBar'
import { CampaignWizardChromeProvider } from '@/components/campaign/shell/CampaignWizardChromeContext'
import { SidebarProvider } from '@/components/ui/Sidebar'
import { WIZARD_MUNICIPALITY_STEP_TITLE } from '@/lib/campaignWizardCopy'
import { WIZARD_MUNICIPALITY_STEP_TITLE, wizardFlowChromeAriaLabel } from '@/lib/campaignWizardCopy'
import { stubMatchMedia } from '../helpers/matchMedia'
const renderWizardShell = (props: React.ComponentProps<typeof CampaignWizardShell>) =>
@ -86,6 +86,37 @@ describe('CampaignWizardShell', () => {
expect(main.getAttribute('aria-labelledby')).toBe(heading.id)
})
it('omits step heading when stepTitle is null and labels main with flow title', () => {
const { container } = renderWizardShell({
flowTitle: 'Ajustar votos',
stepTitle: null,
isEntryStep: true,
previousHref: '/campanha',
children: 'Corpo',
})
expect(screen.queryByRole('heading', { level: 1 })).toBeNull()
const main = within(container).getByRole('main')
expect(main.getAttribute('aria-labelledby')).toBeNull()
expect(main.getAttribute('aria-label')).toBe(wizardFlowChromeAriaLabel('Ajustar votos'))
})
it('uses tighter top padding on mobile when step title is omitted', () => {
const { container } = renderWizardShell({
flowTitle: 'Ajustar votos',
stepTitle: null,
isEntryStep: true,
previousHref: '/campanha',
children: null,
})
const main = container.querySelector('main')
expect(main?.className).toMatch(/pt-2/)
expect(main?.className).not.toMatch(/pt-3/)
expect(main?.className).toMatch(/pb-6/)
expect(main?.className).toMatch(/md:py-6/)
})
it('moves focus to the step title when stepTitle changes', () => {
const { rerender } = render(
<SidebarProvider>