B108 — wizard autofocus first input + active scenario highlight

- CampaignWizardShell: contentFocus prop skips title focus on form steps
- Wizard steps with inputs: contentFocus=none + autoFocus on first field
- VoteEstimateScenarioInputs compact: highlight follows activeScenario
- Municipality list compact overlay tracks focused scenario for highlight
- Unit tests for shell focus mode, compact classes, and votes step

Co-authored-by: Francisco Solla <fsolla@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-08-01 18:59:42 +00:00
parent 97ae71b0fe
commit a52a622ba5
No known key found for this signature in database
11 changed files with 144 additions and 3 deletions

View file

@ -128,6 +128,7 @@ export const WizardLeadershipStep = ({
municipalityLabel={municipalityName}
skip={skipConfig}
trailingAction={trailingAction}
contentFocus={mode.kind === 'form' ? 'none' : 'title'}
>
{mode.kind === 'form' ? (
<WizardLeadershipForm

View file

@ -1,5 +1,7 @@
'use client'
import { useState } from 'react'
import {
MUNICIPALITY_EXPECTED_VOTES_ENDPOINT,
MUNICIPALITY_EXPECTED_VOTES_SAVE_ERROR_MESSAGE,
@ -17,6 +19,7 @@ import {
DEFAULT_VOTE_ESTIMATE_SCENARIO,
voteEstimateScenarioLabels,
voteEstimatesEqual,
type VoteEstimateScenario,
type VoteEstimateScenarioViewModel,
} from '@/lib/voteEstimate'
import type { MunicipalityPledgeCoverageView } from '@/utilities/votePledgeViews'
@ -50,6 +53,7 @@ export const MunicipalityListExpectedVotesControl = ({
}: MunicipalityListExpectedVotesControlProps) => {
const scenarioContext = useMunicipalityEstimateScenarioOptional()
const activeScenario = scenarioContext?.scenario ?? DEFAULT_VOTE_ESTIMATE_SCENARIO
const [focusedScenario, setFocusedScenario] = useState<VoteEstimateScenario>('central')
const { open, onOpenChange, value, change, isPending, errorMessage, statusMessage } =
useCampaignCellAutosave<VoteEstimateScenarioViewModel, MunicipalityListExpectedVotesResponse>({
value: expectedVotes,
@ -114,6 +118,8 @@ export const MunicipalityListExpectedVotesControl = ({
// point. On touch the Drawer would raise the virtual keyboard before
// the field is even read, so the first tap picks the scenario.
autoFocusScenario={variant === 'sheet' ? undefined : 'central'}
activeScenario={focusedScenario}
onFocusScenario={setFocusedScenario}
onValuesChange={(values) => change(values, AUTOSAVE_MS)}
/>
{errorMessage ? (

View file

@ -64,6 +64,7 @@ export const WizardSignalBodyStep = ({
municipalityLabel={municipalityName}
skip={skip}
trailingAction={skip ? <WizardSignalSkipTrailing skip={skip} /> : undefined}
contentFocus="none"
>
<form
action={submitAction}
@ -86,6 +87,7 @@ export const WizardSignalBodyStep = ({
maxLength={5000}
required
disabled={isPending}
autoFocus
className="min-h-28"
placeholder="Descreva o fato com o contexto que a equipe precisa lembrar depois."
/>

View file

@ -67,6 +67,7 @@ export const WizardTrendNoteStep = ({
municipalityLabel={municipalityName}
skip={skip}
trailingAction={skip ? <WizardTrendSkipTrailing skip={skip} /> : undefined}
contentFocus="none"
>
<form
action={submitAction}
@ -91,6 +92,7 @@ export const WizardTrendNoteStep = ({
maxLength={2000}
required
disabled={isPending}
autoFocus
className="min-h-28"
placeholder="Descreva o que mudou na leitura política do município."
/>

View file

@ -22,6 +22,8 @@ export type CampaignWizardShellProps = {
skip?: CampaignWizardChromeSkip
trailingAction?: ReactNode
contentAlign?: 'start' | 'end'
/** When `'none'`, the shell does not move focus to the step title — use on form steps that autofocus an input. */
contentFocus?: 'title' | 'none'
children: ReactNode
}
@ -35,6 +37,7 @@ export const CampaignWizardShell = ({
skip,
trailingAction,
contentAlign = 'start',
contentFocus = 'title',
children,
}: CampaignWizardShellProps) => {
const hasStepTitle = stepTitle != null && stepTitle !== ''
@ -57,9 +60,9 @@ export const CampaignWizardShell = ({
useSetCampaignWizardChrome(chrome)
useEffect(() => {
if (!hasStepTitle) return
if (!hasStepTitle || contentFocus === 'none') return
titleRef.current?.focus()
}, [hasStepTitle, stepTitle])
}, [contentFocus, hasStepTitle, stepTitle])
return (
<div className="flex min-h-full w-full flex-col">

View file

@ -136,6 +136,7 @@ export const WizardExpectedVotesStep = ({
municipalityLabel={municipalityName}
skip={skip}
trailingAction={trailingAction}
contentFocus="none"
>
<div
className="flex flex-col gap-6"

View file

@ -181,6 +181,7 @@ export const WizardMunicipalitySearchStep = ({
isEntryStep
previousHref={previousHref}
dismissHref={previousHref}
contentFocus="none"
>
<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">
@ -191,6 +192,7 @@ export const WizardMunicipalitySearchStep = ({
value={query.raw}
onChange={(event) => setRaw(event.target.value)}
autoComplete="off"
autoFocus
enterKeyHint="search"
aria-busy={resultsBusy || undefined}
maxLength={HOME_SEARCH_QUERY_MAX_LENGTH}

View file

@ -128,7 +128,7 @@ const CompactVoteEstimateScenarioInputs = ({
compactInputClassName,
errorScenarios?.has(scenario)
? 'border-destructive ring-1 ring-destructive/30'
: scenario === 'central'
: scenario === activeScenario
? 'border-primary/50 bg-primary/5 font-medium ring-1 ring-primary/15'
: 'bg-muted/30',
)}

View file

@ -117,6 +117,44 @@ describe('CampaignWizardShell', () => {
expect(main?.className).toMatch(/md:py-6/)
})
it('skips title focus when contentFocus is none', () => {
const { rerender } = render(
<SidebarProvider>
<CampaignWizardChromeProvider>
<CampaignMobileTopBar />
<CampaignWizardShell
flowTitle="Ajustar votos"
stepTitle="Passo 1"
isEntryStep
previousHref="/campanha"
contentFocus="none"
>
<input aria-label="Primeiro campo" />
</CampaignWizardShell>
</CampaignWizardChromeProvider>
</SidebarProvider>,
)
rerender(
<SidebarProvider>
<CampaignWizardChromeProvider>
<CampaignMobileTopBar />
<CampaignWizardShell
flowTitle="Ajustar votos"
stepTitle="Passo 2"
isEntryStep
previousHref="/campanha"
contentFocus="none"
>
<input aria-label="Primeiro campo" />
</CampaignWizardShell>
</CampaignWizardChromeProvider>
</SidebarProvider>,
)
expect(document.activeElement).not.toBe(screen.getByRole('heading', { level: 1, name: 'Passo 2' }))
})
it('moves focus to the step title when stepTitle changes', () => {
const { rerender } = render(
<SidebarProvider>

View file

@ -0,0 +1,71 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { VoteEstimateScenarioInputs } from '@/components/campaign/votePledge/VoteEstimateScenarioInputs'
const defaultValues = { pessimistic: 80, central: 100, optimistic: 120 }
const renderCompact = (
props: Partial<React.ComponentProps<typeof VoteEstimateScenarioInputs>> = {},
) =>
render(
<VoteEstimateScenarioInputs
fieldPrefix="expectedVotes"
values={defaultValues}
idPrefix="test-votes"
variant="compact"
activeScenario="central"
onValuesChange={vi.fn()}
{...props}
/>,
)
describe('VoteEstimateScenarioInputs compact', () => {
afterEach(() => {
cleanup()
})
it('highlights the active scenario instead of always central', () => {
const { rerender } = renderCompact({ activeScenario: 'central' })
const central = screen.getByLabelText('Média')
const optimistic = screen.getByLabelText('Otimista')
expect(central.className).toMatch(/border-primary/)
expect(optimistic.className).toMatch(/bg-muted/)
rerender(
<VoteEstimateScenarioInputs
fieldPrefix="expectedVotes"
values={defaultValues}
idPrefix="test-votes"
variant="compact"
activeScenario="optimistic"
onValuesChange={vi.fn()}
/>,
)
expect(screen.getByLabelText('Média').className).toMatch(/bg-muted/)
expect(screen.getByLabelText('Otimista').className).toMatch(/border-primary/)
})
it('keeps destructive styling ahead of active highlight', () => {
renderCompact({
activeScenario: 'optimistic',
errorScenarios: new Set(['optimistic']),
})
const optimistic = screen.getByLabelText('Otimista')
expect(optimistic.className).toMatch(/border-destructive/)
expect(optimistic.className).not.toMatch(/border-primary/)
expect(optimistic.getAttribute('aria-invalid')).toBe('true')
})
it('updates active scenario on focus', () => {
const onFocusScenario = vi.fn()
renderCompact({ onFocusScenario })
fireEvent.focus(screen.getByLabelText('Pessimista'))
expect(onFocusScenario).toHaveBeenCalledWith('pessimistic')
})
})

View file

@ -55,6 +55,21 @@ describe('WizardExpectedVotesStep', () => {
expect((screen.getByLabelText('Média') as HTMLInputElement).value).toBe('200')
})
it('highlights the last focused scenario in compact inputs', () => {
renderVotesStep({ ...defaultProps })
const central = screen.getByLabelText('Média')
const optimistic = screen.getByLabelText('Otimista')
expect(central.className).toMatch(/border-primary/)
expect(optimistic.className).toMatch(/bg-muted/)
fireEvent.focus(optimistic)
expect(central.className).toMatch(/bg-muted/)
expect(optimistic.className).toMatch(/border-primary/)
})
it('shows violation alert and highlights inputs without navigation', () => {
renderVotesStep({ ...defaultProps })