mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
Merge pull request #147 from fsolla/cursor/pool-i143-27c538e8-dc86
B108 — Wizard autofocus do 1º input + destaque do último cenário focado
This commit is contained in:
commit
7317eb335a
11 changed files with 146 additions and 3 deletions
|
|
@ -128,6 +128,7 @@ export const WizardLeadershipStep = ({
|
|||
municipalityLabel={municipalityName}
|
||||
skip={skipConfig}
|
||||
trailingAction={trailingAction}
|
||||
contentFocus={mode.kind === 'form' ? 'none' : 'title'}
|
||||
>
|
||||
{mode.kind === 'form' ? (
|
||||
<WizardLeadershipForm
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -136,6 +136,7 @@ export const WizardExpectedVotesStep = ({
|
|||
municipalityLabel={municipalityName}
|
||||
skip={skip}
|
||||
trailingAction={trailingAction}
|
||||
contentFocus="none"
|
||||
>
|
||||
<div
|
||||
className="flex flex-col gap-6"
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -117,6 +117,46 @@ 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>
|
||||
|
|
|
|||
71
tests/unit/voteEstimateScenarioInputs.unit.spec.tsx
Normal file
71
tests/unit/voteEstimateScenarioInputs.unit.spec.tsx
Normal 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(/bg-primary\/5/)
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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 })
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue