teqo/src/components/campaign/VoteEstimateScenarioStrip.tsx
Francisco Ignácio Fontoura Solla aa12e173d8 Ship A10 vote estimate scenarios (pessimistic/central/optimistic) for Praças.
Staff surfaces share one Cenário control; leaders keep a single declaredVotes value. Unlocks E8.
2026-07-23 19:12:22 -03:00

147 lines
5.3 KiB
TypeScript

import { cn } from '@/lib/utils'
import {
formatVoteEstimateScenarioAriaLabel,
VOTE_ESTIMATE_SCENARIOS,
voteEstimateScenarioLabels,
type VoteEstimateScenario,
type VoteEstimateScenarioViewModel,
} from '@/utilities/voteEstimate'
const voteFormatter = new Intl.NumberFormat('pt-BR')
type VoteEstimateScenarioStripProps = {
values: VoteEstimateScenarioViewModel
activeScenario?: VoteEstimateScenario
className?: string
/** @deprecated Prefer labelMode */
showLabels?: boolean
labelMode?: 'all' | 'endpoints' | 'none'
markerMode?: 'all' | 'active-only'
stretch?: boolean
}
export const VoteEstimateScenarioStrip = ({
values,
activeScenario = 'central',
className,
showLabels = true,
labelMode,
markerMode = 'all',
stretch = false,
}: VoteEstimateScenarioStripProps) => {
const resolvedLabelMode = labelMode ?? (showLabels ? 'all' : 'none')
const activeOnlyMarkers = markerMode === 'active-only'
const trackClassName = stretch
? 'w-full min-w-0 max-w-none'
: 'w-full min-w-[4.5rem] max-w-[5.5rem]'
const filled = VOTE_ESTIMATE_SCENARIOS.map((scenario) => values[scenario]).filter(
(value): value is number => value != null,
)
const min = filled.length > 0 ? Math.min(...filled) : null
const max = filled.length > 0 ? Math.max(...filled) : null
const hasSpread = min != null && max != null && max > min
const activeValue = values[activeScenario]
const activeMarkerPosition =
activeValue != null && min != null && max != null
? hasSpread
? ((activeValue - min) / (max - min)) * 100
: 50
: null
const activeMarker =
activeMarkerPosition != null ? (
<span
title={`${voteEstimateScenarioLabels[activeScenario]}: ${voteFormatter.format(activeValue!)}`}
className="absolute top-1/2 size-1.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary/45"
style={{ left: `${activeMarkerPosition}%` }}
/>
) : null
return (
<div
className={cn('flex flex-col gap-1', className)}
role="img"
aria-label={`Cenários de estimativa: ${formatVoteEstimateScenarioAriaLabel(values)}`}
>
{activeOnlyMarkers ? (
<div className={cn('relative h-1 rounded-full bg-muted', trackClassName)}>
{activeMarker}
</div>
) : hasSpread ? (
<div className={cn('relative h-1.5 rounded-full bg-muted', trackClassName)}>
{VOTE_ESTIMATE_SCENARIOS.map((scenario) => {
const value = values[scenario]
if (value == null) return null
const position = ((value - min) / (max - min)) * 100
const isActive = scenario === activeScenario
return (
<span
key={scenario}
title={`${voteEstimateScenarioLabels[scenario]}: ${voteFormatter.format(value)}`}
className={cn(
'absolute top-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full',
isActive ? 'size-2.5 bg-primary ring-2 ring-primary/25' : 'size-2 bg-primary/55',
)}
style={{ left: `${position}%` }}
/>
)
})}
</div>
) : (
<div className={cn('flex gap-0.5', trackClassName)}>
{VOTE_ESTIMATE_SCENARIOS.map((scenario) => {
const value = values[scenario]
const isActive = scenario === activeScenario
return (
<span
key={scenario}
title={
value == null
? voteEstimateScenarioLabels[scenario]
: `${voteEstimateScenarioLabels[scenario]}: ${voteFormatter.format(value)}`
}
className={cn(
'h-1.5 flex-1 rounded-full',
value == null ? 'bg-muted' : isActive ? 'bg-primary' : 'bg-primary/45',
)}
/>
)
})}
</div>
)}
{resolvedLabelMode === 'all' ? (
<div className="flex justify-between gap-1 text-xs leading-none text-muted-foreground tabular-nums">
{VOTE_ESTIMATE_SCENARIOS.map((scenario) => {
const value = values[scenario]
return (
<span
key={scenario}
className={cn(
'min-w-0 truncate',
scenario === activeScenario && value != null
? 'font-medium text-foreground'
: undefined,
)}
title={voteEstimateScenarioLabels[scenario]}
>
{value == null ? '·' : voteFormatter.format(value)}
</span>
)
})}
</div>
) : resolvedLabelMode === 'endpoints' ? (
<div className="flex justify-between gap-2 text-xs leading-none text-muted-foreground tabular-nums">
<span className="min-w-0 truncate" title={voteEstimateScenarioLabels.pessimistic}>
{values.pessimistic == null ? '·' : voteFormatter.format(values.pessimistic)}
</span>
<span
className="min-w-0 truncate text-right"
title={voteEstimateScenarioLabels.optimistic}
>
{values.optimistic == null ? '·' : voteFormatter.format(values.optimistic)}
</span>
</div>
) : null}
</div>
)
}