From a52a622ba50ef2fb43b1603f556bc73cd4178e19 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 1 Aug 2026 18:59:42 +0000 Subject: [PATCH] =?UTF-8?q?B108=20=E2=80=94=20wizard=20autofocus=20first?= =?UTF-8?q?=20input=20+=20active=20scenario=20highlight?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../leadership/WizardLeadershipStep.tsx | 1 + .../MunicipalityListExpectedVotesControl.tsx | 6 ++ .../municipality/WizardSignalBodyStep.tsx | 2 + .../municipality/WizardTrendNoteStep.tsx | 2 + .../campaign/shared/CampaignWizardShell.tsx | 7 +- .../shared/WizardExpectedVotesStep.tsx | 1 + .../shared/WizardMunicipalitySearchStep.tsx | 2 + .../votePledge/VoteEstimateScenarioInputs.tsx | 2 +- tests/unit/campaignWizardShell.unit.spec.tsx | 38 ++++++++++ .../voteEstimateScenarioInputs.unit.spec.tsx | 71 +++++++++++++++++++ .../wizardExpectedVotesStep.unit.spec.tsx | 15 ++++ 11 files changed, 144 insertions(+), 3 deletions(-) create mode 100644 tests/unit/voteEstimateScenarioInputs.unit.spec.tsx diff --git a/src/components/campaign/leadership/WizardLeadershipStep.tsx b/src/components/campaign/leadership/WizardLeadershipStep.tsx index 6dcdfb52..ca4ed62d 100644 --- a/src/components/campaign/leadership/WizardLeadershipStep.tsx +++ b/src/components/campaign/leadership/WizardLeadershipStep.tsx @@ -128,6 +128,7 @@ export const WizardLeadershipStep = ({ municipalityLabel={municipalityName} skip={skipConfig} trailingAction={trailingAction} + contentFocus={mode.kind === 'form' ? 'none' : 'title'} > {mode.kind === 'form' ? ( { const scenarioContext = useMunicipalityEstimateScenarioOptional() const activeScenario = scenarioContext?.scenario ?? DEFAULT_VOTE_ESTIMATE_SCENARIO + const [focusedScenario, setFocusedScenario] = useState('central') const { open, onOpenChange, value, change, isPending, errorMessage, statusMessage } = useCampaignCellAutosave({ 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 ? ( diff --git a/src/components/campaign/municipality/WizardSignalBodyStep.tsx b/src/components/campaign/municipality/WizardSignalBodyStep.tsx index 9deff264..a3313969 100644 --- a/src/components/campaign/municipality/WizardSignalBodyStep.tsx +++ b/src/components/campaign/municipality/WizardSignalBodyStep.tsx @@ -64,6 +64,7 @@ export const WizardSignalBodyStep = ({ municipalityLabel={municipalityName} skip={skip} trailingAction={skip ? : undefined} + contentFocus="none" >
diff --git a/src/components/campaign/municipality/WizardTrendNoteStep.tsx b/src/components/campaign/municipality/WizardTrendNoteStep.tsx index 85f8c8d5..f416d24d 100644 --- a/src/components/campaign/municipality/WizardTrendNoteStep.tsx +++ b/src/components/campaign/municipality/WizardTrendNoteStep.tsx @@ -67,6 +67,7 @@ export const WizardTrendNoteStep = ({ municipalityLabel={municipalityName} skip={skip} trailingAction={skip ? : undefined} + contentFocus="none" > diff --git a/src/components/campaign/shared/CampaignWizardShell.tsx b/src/components/campaign/shared/CampaignWizardShell.tsx index ab895c24..54882bbe 100644 --- a/src/components/campaign/shared/CampaignWizardShell.tsx +++ b/src/components/campaign/shared/CampaignWizardShell.tsx @@ -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 (
diff --git a/src/components/campaign/shared/WizardExpectedVotesStep.tsx b/src/components/campaign/shared/WizardExpectedVotesStep.tsx index 6a3bf77d..5d2438a8 100644 --- a/src/components/campaign/shared/WizardExpectedVotesStep.tsx +++ b/src/components/campaign/shared/WizardExpectedVotesStep.tsx @@ -136,6 +136,7 @@ export const WizardExpectedVotesStep = ({ municipalityLabel={municipalityName} skip={skip} trailingAction={trailingAction} + contentFocus="none" >
@@ -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} diff --git a/src/components/campaign/votePledge/VoteEstimateScenarioInputs.tsx b/src/components/campaign/votePledge/VoteEstimateScenarioInputs.tsx index b9d17c2f..01afc6ff 100644 --- a/src/components/campaign/votePledge/VoteEstimateScenarioInputs.tsx +++ b/src/components/campaign/votePledge/VoteEstimateScenarioInputs.tsx @@ -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', )} diff --git a/tests/unit/campaignWizardShell.unit.spec.tsx b/tests/unit/campaignWizardShell.unit.spec.tsx index 9332a2db..30290c25 100644 --- a/tests/unit/campaignWizardShell.unit.spec.tsx +++ b/tests/unit/campaignWizardShell.unit.spec.tsx @@ -117,6 +117,44 @@ describe('CampaignWizardShell', () => { expect(main?.className).toMatch(/md:py-6/) }) + it('skips title focus when contentFocus is none', () => { + const { rerender } = render( + + + + + + + + , + ) + + rerender( + + + + + + + + , + ) + + 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( diff --git a/tests/unit/voteEstimateScenarioInputs.unit.spec.tsx b/tests/unit/voteEstimateScenarioInputs.unit.spec.tsx new file mode 100644 index 00000000..a440dfdd --- /dev/null +++ b/tests/unit/voteEstimateScenarioInputs.unit.spec.tsx @@ -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> = {}, +) => + render( + , + ) + +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( + , + ) + + 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') + }) +}) diff --git a/tests/unit/wizardExpectedVotesStep.unit.spec.tsx b/tests/unit/wizardExpectedVotesStep.unit.spec.tsx index 3295c970..46cae242 100644 --- a/tests/unit/wizardExpectedVotesStep.unit.spec.tsx +++ b/tests/unit/wizardExpectedVotesStep.unit.spec.tsx @@ -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 })