feat(know-your-world): add range thermometer for region size selection

Replace checkbox-based region size selector with a dual-handle range slider:
- Create reusable Thermometer components (RangeThermometer, SingleThermometer)
- Use Radix UI Slider for accessible range selection
- Add hover preview showing which regions would be added/removed on click
- Smooth react-spring animations for map region color transitions
- Fix slider thrashing by tracking last-sent values with ref
- Handle drag state to prevent hover interference during slider manipulation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock 2025-11-27 11:40:24 -06:00
parent 98e74bae3a
commit c7c4e7cef3
6 changed files with 873 additions and 160 deletions

View File

@ -1,8 +1,12 @@
'use client'
import { useState, useCallback, useMemo, useRef, useEffect } from 'react'
import * as Checkbox from '@radix-ui/react-checkbox'
import { css } from '@styled/css'
import {
RangeThermometer,
type ThermometerOption,
type RangePreviewState,
} from '@/components/Thermometer'
import { useTheme } from '@/contexts/ThemeContext'
import { MapSelectorMap } from './MapSelectorMap'
import {
@ -27,6 +31,35 @@ import {
type ContinentId,
} from '../continents'
/**
* Size options for the range thermometer, ordered from largest to smallest
*/
const SIZE_OPTIONS: ThermometerOption<RegionSize>[] = ALL_REGION_SIZES.map((size) => ({
value: size,
label: REGION_SIZE_CONFIG[size].label,
shortLabel: REGION_SIZE_CONFIG[size].label,
emoji: REGION_SIZE_CONFIG[size].emoji,
}))
/**
* Convert an array of sizes to min/max values for the range thermometer
*/
function sizesToRange(sizes: RegionSize[]): [RegionSize, RegionSize] {
const sorted = [...sizes].sort(
(a, b) => ALL_REGION_SIZES.indexOf(a) - ALL_REGION_SIZES.indexOf(b)
)
return [sorted[0], sorted[sorted.length - 1]]
}
/**
* Convert min/max range values back to an array of sizes
*/
function rangeToSizes(min: RegionSize, max: RegionSize): RegionSize[] {
const minIdx = ALL_REGION_SIZES.indexOf(min)
const maxIdx = ALL_REGION_SIZES.indexOf(max)
return ALL_REGION_SIZES.slice(minIdx, maxIdx + 1)
}
/**
* Selection path for drill-down navigation:
* - [] = World level
@ -111,6 +144,9 @@ export function DrillDownMapSelector({
const [path, setPath] = useState<SelectionPath>(getInitialPath)
const [hoveredRegion, setHoveredRegion] = useState<string | null>(null)
const [sizeRangePreview, setSizeRangePreview] = useState<RangePreviewState<RegionSize> | null>(
null
)
const [containerDimensions, setContainerDimensions] = useState({ width: 0, height: 0 })
const containerRef = useRef<HTMLDivElement>(null)
@ -276,6 +312,54 @@ export function DrillDownMapSelector({
return excluded
}, [currentLevel, path, selectedContinent, includeSizes])
// Calculate preview regions based on hovering over the size thermometer
// Shows what regions would be added or removed if the user clicks
const { previewAddRegions, previewRemoveRegions } = useMemo(() => {
if (!sizeRangePreview) {
return { previewAddRegions: [], previewRemoveRegions: [] }
}
// Determine which map we're looking at
const mapId = currentLevel === 2 && path[1] ? 'usa' : 'world'
const continentId: ContinentId | 'all' =
currentLevel >= 1 && path[0] ? path[0] : selectedContinent
// Get current included region IDs
const currentIncluded = getFilteredMapDataBySizesSync(
mapId as 'world' | 'usa',
continentId,
includeSizes
)
const currentIncludedIds = new Set(currentIncluded.regions.map((r) => r.id))
// Get preview included region IDs (if user clicked)
const previewSizes = rangeToSizes(sizeRangePreview.previewMin, sizeRangePreview.previewMax)
const previewIncluded = getFilteredMapDataBySizesSync(
mapId as 'world' | 'usa',
continentId,
previewSizes
)
const previewIncludedIds = new Set(previewIncluded.regions.map((r) => r.id))
// Regions that would be ADDED (in preview but not currently included)
const addRegions: string[] = []
for (const id of previewIncludedIds) {
if (!currentIncludedIds.has(id)) {
addRegions.push(id)
}
}
// Regions that would be REMOVED (currently included but not in preview)
const removeRegions: string[] = []
for (const id of currentIncludedIds) {
if (!previewIncludedIds.has(id)) {
removeRegions.push(id)
}
}
return { previewAddRegions: addRegions, previewRemoveRegions: removeRegions }
}, [sizeRangePreview, currentLevel, path, selectedContinent, includeSizes])
// Compute the label to display for the hovered region
// Shows the next drill-down level name, not the individual region name
const hoveredLabel = useMemo(() => {
@ -623,6 +707,8 @@ export function DrillDownMapSelector({
}
hoverableRegions={currentLevel === 1 ? highlightedRegions : undefined}
excludedRegions={excludedRegions}
previewAddRegions={previewAddRegions}
previewRemoveRegions={previewRemoveRegions}
/>
{/* Zoom Out Button - positioned inside map, upper right */}
@ -725,17 +811,13 @@ export function DrillDownMapSelector({
)
})()}
{/* Region Size Filters - positioned inside map, right side as column */}
{/* Region Size Range Selector - positioned inside map, right side */}
<div
data-element="region-size-filters"
className={css({
position: 'absolute',
top: '3',
right: '3',
bottom: '3',
display: 'flex',
flexDirection: 'column',
gap: '1',
padding: '2',
bg: isDark ? 'gray.800/90' : 'white/90',
backdropFilter: 'blur(4px)',
@ -746,147 +828,17 @@ export function DrillDownMapSelector({
zIndex: 10,
})}
>
{/* Select All button */}
<button
data-action="select-all-sizes"
onClick={() => onRegionSizesChange([...ALL_REGION_SIZES])}
disabled={includeSizes.length === ALL_REGION_SIZES.length}
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '1',
paddingX: '2',
paddingY: '1',
bg: isDark ? 'green.800' : 'green.500',
color: 'white',
border: '1px solid',
borderColor: isDark ? 'green.600' : 'green.600',
rounded: 'md',
cursor: includeSizes.length === ALL_REGION_SIZES.length ? 'default' : 'pointer',
opacity: includeSizes.length === ALL_REGION_SIZES.length ? 0.5 : 1,
transition: 'all 0.15s',
fontSize: 'xs',
fontWeight: '600',
_hover:
includeSizes.length === ALL_REGION_SIZES.length
? {}
: {
bg: isDark ? 'green.700' : 'green.600',
},
})}
>
<span></span>
<span>All</span>
</button>
{/* Size checkboxes in a column */}
<div
className={css({
display: 'flex',
flexDirection: 'column',
gap: '1',
flex: 1,
})}
>
{ALL_REGION_SIZES.map((size) => {
const config = REGION_SIZE_CONFIG[size]
const isChecked = includeSizes.includes(size)
const isOnlyOne = includeSizes.length === 1 && isChecked
const count = regionCountsBySize[size] || 0
const handleToggle = () => {
if (isOnlyOne) return
if (isChecked) {
onRegionSizesChange(includeSizes.filter((s) => s !== size))
} else {
onRegionSizesChange([...includeSizes, size])
}
}
return (
<Checkbox.Root
key={size}
checked={isChecked}
onCheckedChange={handleToggle}
disabled={isOnlyOne}
className={css({
display: 'flex',
alignItems: 'center',
gap: '1',
paddingX: '2',
paddingY: '1',
bg: isChecked
? isDark
? 'blue.800'
: 'blue.500'
: isDark
? 'gray.700'
: 'gray.100',
border: '1px solid',
borderColor: isChecked
? isDark
? 'blue.600'
: 'blue.600'
: isDark
? 'gray.600'
: 'gray.300',
rounded: 'full',
cursor: isOnlyOne ? 'not-allowed' : 'pointer',
opacity: isOnlyOne ? 0.5 : 1,
transition: 'all 0.15s',
fontSize: 'xs',
_hover: isOnlyOne
? {}
: {
bg: isChecked
? isDark
? 'blue.700'
: 'blue.600'
: isDark
? 'gray.600'
: 'gray.200',
},
})}
>
<span>{config.emoji}</span>
<span
className={css({
fontWeight: '500',
color: isChecked ? 'white' : isDark ? 'gray.200' : 'gray.700',
})}
>
{config.label}
</span>
<span
className={css({
fontWeight: '600',
color: isChecked
? isDark
? 'blue.200'
: 'blue.100'
: isDark
? 'gray.400'
: 'gray.500',
bg: isChecked
? isDark
? 'blue.700'
: 'blue.600'
: isDark
? 'gray.600'
: 'gray.200',
paddingX: '1',
rounded: 'full',
minWidth: '4',
textAlign: 'center',
})}
>
{count}
</span>
</Checkbox.Root>
)
})}
</div>
<RangeThermometer
options={SIZE_OPTIONS}
minValue={sizesToRange(includeSizes)[0]}
maxValue={sizesToRange(includeSizes)[1]}
onChange={(min, max) => onRegionSizesChange(rangeToSizes(min, max))}
orientation="vertical"
isDark={isDark}
counts={regionCountsBySize as Partial<Record<RegionSize, number>>}
showTotalCount
onHoverPreview={setSizeRangePreview}
/>
</div>
</div>

View File

@ -1,11 +1,74 @@
'use client'
import { useMemo } from 'react'
import { useMemo, memo } from 'react'
import { css } from '@styled/css'
import { animated, useSpring } from '@react-spring/web'
import { useTheme } from '@/contexts/ThemeContext'
import type { MapData } from '../types'
import { getRegionColor } from '../mapColors'
/**
* Animated SVG path component for smooth color/style transitions
*/
interface AnimatedRegionProps {
id: string
path: string
fill: string
stroke: string
strokeWidth: number
opacity: number
isExcluded: boolean
isPreviewAdd: boolean
isPreviewRemove: boolean
onMouseEnter: () => void
onMouseLeave: () => void
onClick: (e: React.MouseEvent) => void
}
const AnimatedRegion = memo(function AnimatedRegion({
id,
path,
fill,
stroke,
strokeWidth,
opacity,
isExcluded,
isPreviewAdd,
isPreviewRemove,
onMouseEnter,
onMouseLeave,
onClick,
}: AnimatedRegionProps) {
const springProps = useSpring({
fill,
stroke,
strokeWidth,
opacity,
config: { duration: 400 },
})
return (
<animated.path
data-region={id}
data-excluded={isExcluded ? 'true' : undefined}
data-preview-add={isPreviewAdd ? 'true' : undefined}
data-preview-remove={isPreviewRemove ? 'true' : undefined}
d={path}
fill={springProps.fill}
stroke={springProps.stroke}
strokeWidth={springProps.strokeWidth}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
onClick={onClick}
style={{
cursor: 'pointer',
pointerEvents: 'all',
opacity: springProps.opacity,
}}
/>
)
})
interface MapSelectorMapProps {
/** Map data to display */
mapData: MapData
@ -43,6 +106,16 @@ interface MapSelectorMapProps {
* These will be shown dimmed/grayed out.
*/
excludedRegions?: string[]
/**
* Regions that would be ADDED to the selection if the user clicks.
* Shown with a distinct "preview add" style (e.g., green tint).
*/
previewAddRegions?: string[]
/**
* Regions that would be REMOVED from the selection if the user clicks.
* Shown with a distinct "preview remove" style (e.g., red/orange tint).
*/
previewRemoveRegions?: string[]
}
export function MapSelectorMap({
@ -57,6 +130,8 @@ export function MapSelectorMap({
selectedGroup,
hoverableRegions,
excludedRegions = [],
previewAddRegions = [],
previewRemoveRegions = [],
}: MapSelectorMapProps) {
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
@ -103,14 +178,36 @@ export function MapSelectorMap({
return excludedRegions.includes(regionId)
}
// Check if a region would be added in a preview
const isRegionPreviewAdd = (regionId: string): boolean => {
return previewAddRegions.includes(regionId)
}
// Check if a region would be removed in a preview
const isRegionPreviewRemove = (regionId: string): boolean => {
return previewRemoveRegions.includes(regionId)
}
// Get fill color for a region
const getRegionFill = (regionId: string): string => {
const isExcluded = isRegionExcluded(regionId)
const isPreviewAdd = isRegionPreviewAdd(regionId)
const isPreviewRemove = isRegionPreviewRemove(regionId)
const isHovered = isRegionHighlighted(regionId)
const isSelected = isRegionSelected(regionId)
const hasSubMap = highlightedRegions.includes(regionId)
// Excluded regions are dimmed
// Preview states take precedence - show what would happen on click
if (isPreviewAdd) {
// Region would be ADDED - show with green/teal tint
return isDark ? '#065f46' : '#a7f3d0'
}
if (isPreviewRemove) {
// Region would be REMOVED - show with amber/orange tint
return isDark ? '#78350f' : '#fde68a'
}
// Excluded regions are dimmed (but not if preview is showing something else)
if (isExcluded) {
return isDark ? '#1f2937' : '#e5e7eb' // Gray out excluded regions
}
@ -140,10 +237,20 @@ export function MapSelectorMap({
// Get stroke color for a region
const getRegionStroke = (regionId: string): string => {
const isExcluded = isRegionExcluded(regionId)
const isPreviewAdd = isRegionPreviewAdd(regionId)
const isPreviewRemove = isRegionPreviewRemove(regionId)
const isHovered = isRegionHighlighted(regionId)
const isSelected = isRegionSelected(regionId)
const hasSubMap = highlightedRegions.includes(regionId)
// Preview states take precedence
if (isPreviewAdd) {
return isDark ? '#10b981' : '#059669' // Green border
}
if (isPreviewRemove) {
return isDark ? '#f59e0b' : '#d97706' // Amber border
}
// Excluded regions get subtle stroke
if (isExcluded) {
return isDark ? '#374151' : '#d1d5db'
@ -167,16 +274,31 @@ export function MapSelectorMap({
// Get stroke width for a region
const getRegionStrokeWidth = (regionId: string): number => {
const isPreviewAdd = isRegionPreviewAdd(regionId)
const isPreviewRemove = isRegionPreviewRemove(regionId)
const isHovered = isRegionHighlighted(regionId)
const isSelected = isRegionSelected(regionId)
const hasSubMap = highlightedRegions.includes(regionId)
if (isPreviewAdd || isPreviewRemove) return 1.5
if (isHovered) return 2
if (isSelected) return 1.5
if (hasSubMap) return 1.5
return 0.5
}
// Get opacity for a region (preview regions should be fully visible)
const getRegionOpacity = (regionId: string): number => {
const isExcluded = isRegionExcluded(regionId)
const isPreviewAdd = isRegionPreviewAdd(regionId)
const isPreviewRemove = isRegionPreviewRemove(regionId)
// Preview states override excluded opacity
if (isPreviewAdd || isPreviewRemove) return 1
if (isExcluded) return 0.5
return 1
}
return (
<div
data-component="map-selector-map"
@ -213,30 +335,29 @@ export function MapSelectorMap({
fill={isDark ? '#111827' : '#e0f2fe'}
/>
{/* Render each region */}
{/* Render each region with smooth animations */}
{displayRegions.map((region) => {
const isExcluded = excludedRegions.includes(region.id)
const isPreviewAdd = previewAddRegions.includes(region.id)
const isPreviewRemove = previewRemoveRegions.includes(region.id)
return (
<path
<AnimatedRegion
key={region.id}
data-region={region.id}
data-excluded={isExcluded ? 'true' : undefined}
d={region.path}
id={region.id}
path={region.path}
fill={getRegionFill(region.id)}
stroke={getRegionStroke(region.id)}
strokeWidth={getRegionStrokeWidth(region.id)}
opacity={getRegionOpacity(region.id)}
isExcluded={isExcluded}
isPreviewAdd={isPreviewAdd}
isPreviewRemove={isPreviewRemove}
onMouseEnter={() => onRegionHover(region.id)}
onMouseLeave={() => onRegionHover(null)}
onClick={(e) => {
e.stopPropagation()
onRegionClick(region.id)
}}
style={{
cursor: 'pointer',
transition: 'all 0.15s ease',
pointerEvents: 'all',
opacity: isExcluded ? 0.5 : 1,
}}
/>
)
})}

View File

@ -0,0 +1,449 @@
'use client'
import { css } from '@styled/css'
import * as Slider from '@radix-ui/react-slider'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { RangeThermometerProps } from './types'
/**
* A range thermometer component that displays discrete options with two handles
* for selecting a min/max range. Uses Radix Slider for accessibility and interaction.
*/
export function RangeThermometer<T extends string>({
options,
minValue,
maxValue,
onChange,
orientation = 'vertical',
isDark = false,
label,
description,
counts,
showTotalCount = true,
onHoverPreview,
}: RangeThermometerProps<T>) {
const isVertical = orientation === 'vertical'
const [isDragging, setIsDragging] = useState(false)
// Track last sent values to prevent duplicate onChange calls during async updates
const lastSentRef = useRef<[number, number] | null>(null)
// Sync ref when props change from outside (e.g., parent resets values)
useEffect(() => {
const currentMinIndex = options.findIndex((opt) => opt.value === minValue)
const currentMaxIndex = options.findIndex((opt) => opt.value === maxValue)
// Only sync if we're not dragging (to avoid overwriting during drag)
if (!isDragging) {
lastSentRef.current = [currentMinIndex, currentMaxIndex]
}
}, [minValue, maxValue, options, isDragging])
// Convert values to indices - memoize to prevent recalculation
const minIndex = useMemo(
() => options.findIndex((opt) => opt.value === minValue),
[options, minValue]
)
const maxIndex = useMemo(
() => options.findIndex((opt) => opt.value === maxValue),
[options, maxValue]
)
// Handle slider value changes - memoized to prevent Slider re-renders
// Only call onChange when values actually change to prevent server thrashing
const handleValueChange = useCallback(
(values: number[]) => {
const [newMinIndex, newMaxIndex] = values
const last = lastSentRef.current
// Only fire if values differ from what we last sent (not props, which may be stale)
if (!last || newMinIndex !== last[0] || newMaxIndex !== last[1]) {
lastSentRef.current = [newMinIndex, newMaxIndex]
onChange(options[newMinIndex].value, options[newMaxIndex].value)
}
},
[onChange, options]
)
// Calculate what the range would be if clicking on a specific option
// (moves nearest handle to that position)
const getPreviewRange = useCallback(
(hoveredIndex: number): { previewMin: T; previewMax: T } => {
const distToMin = Math.abs(hoveredIndex - minIndex)
const distToMax = Math.abs(hoveredIndex - maxIndex)
if (distToMin <= distToMax) {
// Would move min handle
return {
previewMin: options[hoveredIndex].value,
previewMax: options[maxIndex].value,
}
} else {
// Would move max handle
return {
previewMin: options[minIndex].value,
previewMax: options[hoveredIndex].value,
}
}
},
[options, minIndex, maxIndex]
)
// Handle hover enter on an option - memoized
// Skip hover events while dragging to prevent interference
const handleOptionHover = useCallback(
(index: number) => {
if (onHoverPreview && !isDragging) {
onHoverPreview(getPreviewRange(index))
}
},
[onHoverPreview, getPreviewRange, isDragging]
)
// Handle hover leave - memoized
// Skip hover events while dragging to prevent interference
const handleOptionLeave = useCallback(() => {
if (onHoverPreview && !isDragging) {
onHoverPreview(null)
}
}, [onHoverPreview, isDragging])
// Handle drag start/end for the slider
const handlePointerDown = useCallback(() => {
setIsDragging(true)
// Clear any existing preview when starting to drag
if (onHoverPreview) {
onHoverPreview(null)
}
}, [onHoverPreview])
const handlePointerUp = useCallback(() => {
setIsDragging(false)
}, [])
// Listen for global pointer up to handle drag release anywhere on screen
useEffect(() => {
if (isDragging) {
const handleGlobalPointerUp = () => {
setIsDragging(false)
}
window.addEventListener('pointerup', handleGlobalPointerUp)
window.addEventListener('pointercancel', handleGlobalPointerUp)
return () => {
window.removeEventListener('pointerup', handleGlobalPointerUp)
window.removeEventListener('pointercancel', handleGlobalPointerUp)
}
}
}, [isDragging])
// Calculate total count for selected range
const totalCount = useMemo(() => {
if (!counts || !showTotalCount) return null
let total = 0
for (let i = minIndex; i <= maxIndex; i++) {
const opt = options[i]
total += counts[opt.value] || 0
}
return total
}, [counts, showTotalCount, options, minIndex, maxIndex])
// Check if an option is within the selected range
const isInRange = (index: number) => index >= minIndex && index <= maxIndex
return (
<div
data-component="range-thermometer"
className={css({
display: 'flex',
flexDirection: 'column',
gap: '2',
})}
>
{/* Label and description */}
{(label || description) && (
<div>
{label && (
<div
className={css({
fontSize: 'xs',
fontWeight: 'semibold',
color: isDark ? 'gray.300' : 'gray.700',
mb: '0.5',
})}
>
{label}
</div>
)}
{description && (
<div
className={css({
fontSize: '2xs',
color: isDark ? 'gray.400' : 'gray.500',
lineHeight: '1.3',
})}
>
{description}
</div>
)}
</div>
)}
{/* Main container with labels and slider */}
<div
data-element="range-thermometer-track"
className={css({
display: 'flex',
flexDirection: isVertical ? 'row' : 'column',
gap: '2',
})}
>
{/* Labels column */}
<div
className={css({
display: 'flex',
flexDirection: isVertical ? 'column' : 'row',
justifyContent: 'space-between',
flex: isVertical ? 'none' : 1,
})}
>
{options.map((option, index) => {
const inRange = isInRange(index)
const count = counts?.[option.value]
return (
<button
key={option.value}
type="button"
data-option={option.value}
data-in-range={inRange}
onClick={() => {
// Click moves nearest handle to this position
const distToMin = Math.abs(index - minIndex)
const distToMax = Math.abs(index - maxIndex)
if (distToMin <= distToMax) {
onChange(option.value, options[maxIndex].value)
} else {
onChange(options[minIndex].value, option.value)
}
}}
onMouseEnter={() => handleOptionHover(index)}
onMouseLeave={handleOptionLeave}
className={css({
display: 'flex',
alignItems: 'center',
gap: '1.5',
py: '1',
px: '2',
rounded: 'md',
cursor: 'pointer',
transition: 'all 0.15s',
bg: inRange ? (isDark ? 'blue.900/40' : 'blue.50') : 'transparent',
opacity: inRange ? 1 : 0.5,
_hover: {
bg: isDark ? 'gray.700' : 'gray.100',
opacity: 1,
},
})}
>
{/* Emoji */}
{option.emoji && <span className={css({ fontSize: 'sm' })}>{option.emoji}</span>}
{/* Label */}
<span
className={css({
fontSize: 'xs',
fontWeight: inRange ? '600' : '500',
color: inRange
? isDark
? 'blue.300'
: 'blue.700'
: isDark
? 'gray.400'
: 'gray.600',
flex: 1,
textAlign: 'left',
})}
>
{option.shortLabel || option.label}
</span>
{/* Count badge */}
{count !== undefined && (
<span
className={css({
fontSize: '2xs',
fontWeight: '600',
color: inRange
? isDark
? 'blue.200'
: 'blue.600'
: isDark
? 'gray.500'
: 'gray.400',
bg: inRange
? isDark
? 'blue.800'
: 'blue.100'
: isDark
? 'gray.700'
: 'gray.200',
px: '1.5',
py: '0.5',
rounded: 'full',
minWidth: '6',
textAlign: 'center',
})}
>
{count}
</span>
)}
</button>
)
})}
</div>
{/* Slider track */}
<div
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: isVertical ? '8' : 'auto',
height: isVertical ? 'auto' : '8',
minHeight: isVertical ? '180px' : 'auto',
})}
>
<Slider.Root
value={[minIndex, maxIndex]}
min={0}
max={options.length - 1}
step={1}
orientation={isVertical ? 'vertical' : 'horizontal'}
inverted={isVertical} // Vertical sliders need inversion so top = index 0
onValueChange={handleValueChange}
onPointerDown={handlePointerDown}
className={css({
position: 'relative',
display: 'flex',
alignItems: 'center',
userSelect: 'none',
touchAction: 'none',
width: isVertical ? '3' : '100%',
height: isVertical ? '100%' : '3',
})}
>
<Slider.Track
className={css({
bg: isDark ? 'gray.700' : 'gray.200',
position: 'relative',
flexGrow: 1,
rounded: 'full',
width: isVertical ? '3' : '100%',
height: isVertical ? '100%' : '3',
})}
>
<Slider.Range
className={css({
position: 'absolute',
bg: isDark ? 'blue.600' : 'blue.500',
rounded: 'full',
width: isVertical ? '100%' : 'auto',
height: isVertical ? 'auto' : '100%',
})}
/>
</Slider.Track>
{/* Min thumb */}
<Slider.Thumb
data-handle="min"
className={css({
display: 'block',
w: '4',
h: '4',
bg: 'white',
border: '2px solid',
borderColor: isDark ? 'blue.400' : 'blue.500',
rounded: 'full',
cursor: 'grab',
boxShadow: '0 2px 4px rgba(0,0,0,0.2)',
_hover: {
bg: isDark ? 'blue.100' : 'blue.50',
transform: 'scale(1.1)',
},
_focus: {
outline: 'none',
boxShadow: '0 0 0 3px rgba(59, 130, 246, 0.3)',
},
_active: {
cursor: 'grabbing',
},
})}
/>
{/* Max thumb */}
<Slider.Thumb
data-handle="max"
className={css({
display: 'block',
w: '4',
h: '4',
bg: 'white',
border: '2px solid',
borderColor: isDark ? 'blue.400' : 'blue.500',
rounded: 'full',
cursor: 'grab',
boxShadow: '0 2px 4px rgba(0,0,0,0.2)',
_hover: {
bg: isDark ? 'blue.100' : 'blue.50',
transform: 'scale(1.1)',
},
_focus: {
outline: 'none',
boxShadow: '0 0 0 3px rgba(59, 130, 246, 0.3)',
},
_active: {
cursor: 'grabbing',
},
})}
/>
</Slider.Root>
</div>
</div>
{/* Total count display */}
{totalCount !== null && (
<div
data-element="total-count"
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '1',
py: '1.5',
px: '2',
bg: isDark ? 'blue.900/30' : 'blue.50',
rounded: 'md',
border: '1px solid',
borderColor: isDark ? 'blue.800' : 'blue.200',
})}
>
<span
className={css({
fontSize: 'sm',
fontWeight: 'bold',
color: isDark ? 'blue.300' : 'blue.700',
})}
>
{totalCount}
</span>
<span
className={css({
fontSize: 'xs',
color: isDark ? 'blue.400' : 'blue.600',
})}
>
regions
</span>
</div>
)}
</div>
)
}

View File

@ -0,0 +1,124 @@
'use client'
import { css } from '@styled/css'
import type { SingleThermometerProps } from './types'
/**
* A single-selection thermometer component that displays discrete options
* in a horizontal or vertical layout. Supports custom overlay rendering
* for additional visual indicators.
*/
export function SingleThermometer<T extends string>({
options,
value,
onChange,
orientation = 'horizontal',
isDark = false,
label,
description,
renderOverlay,
}: SingleThermometerProps<T>) {
const isHorizontal = orientation === 'horizontal'
return (
<div
data-component="single-thermometer"
className={css({
display: 'flex',
flexDirection: 'column',
gap: '1.5',
})}
>
{/* Label and description */}
{(label || description) && (
<div>
{label && (
<div
className={css({
fontSize: 'xs',
fontWeight: 'semibold',
color: isDark ? 'gray.300' : 'gray.700',
mb: '0.5',
})}
>
{label}
</div>
)}
{description && (
<div
className={css({
fontSize: '2xs',
color: isDark ? 'gray.400' : 'gray.500',
lineHeight: '1.3',
})}
>
{description}
</div>
)}
</div>
)}
{/* Thermometer track */}
<div
data-element="thermometer-track"
className={css({
display: 'flex',
flexDirection: isHorizontal ? 'row' : 'column',
gap: '0',
bg: isDark ? 'gray.700' : 'gray.100',
rounded: 'md',
p: '1',
border: '1px solid',
borderColor: isDark ? 'gray.600' : 'gray.200',
})}
>
{options.map((option, index) => {
const isSelected = value === option.value
const isFirst = index === 0
const isLast = index === options.length - 1
return (
<button
key={option.value}
type="button"
data-option={option.value}
data-selected={isSelected}
onClick={() => onChange(option.value)}
title={option.label}
className={css({
flex: 1,
px: '2',
py: '1.5',
fontSize: '2xs',
fontWeight: isSelected ? 'bold' : 'medium',
color: isSelected ? 'white' : isDark ? 'gray.400' : 'gray.600',
bg: isSelected ? 'brand.500' : 'transparent',
// Rounded corners only on edges
borderTopLeftRadius: isHorizontal ? (isFirst ? 'md' : '0') : isFirst ? 'md' : '0',
borderBottomLeftRadius: isHorizontal ? (isFirst ? 'md' : '0') : isLast ? 'md' : '0',
borderTopRightRadius: isHorizontal ? (isLast ? 'md' : '0') : isFirst ? 'md' : '0',
borderBottomRightRadius: isHorizontal ? (isLast ? 'md' : '0') : isLast ? 'md' : '0',
cursor: 'pointer',
transition: 'all 0.15s',
position: 'relative',
_hover: {
bg: isSelected ? 'brand.600' : isDark ? 'gray.600' : 'gray.200',
color: isSelected ? 'white' : isDark ? 'gray.200' : 'gray.800',
},
})}
>
{/* Custom overlay content (e.g., auto-resolve indicator) */}
{renderOverlay?.(option, index, isSelected)}
{/* Option content */}
<span className={css({ display: 'flex', alignItems: 'center', gap: '1' })}>
{option.emoji && <span>{option.emoji}</span>}
<span>{option.shortLabel || option.label}</span>
</span>
</button>
)
})}
</div>
</div>
)
}

View File

@ -0,0 +1,9 @@
export { SingleThermometer } from './SingleThermometer'
export { RangeThermometer } from './RangeThermometer'
export type {
ThermometerOption,
ThermometerBaseProps,
SingleThermometerProps,
RangeThermometerProps,
RangePreviewState,
} from './types'

View File

@ -0,0 +1,58 @@
import type { ReactNode } from 'react'
/**
* Generic option type for thermometer components
*/
export interface ThermometerOption<T extends string> {
value: T
label: string
shortLabel?: string
emoji?: string
}
/**
* Common props shared between single and range thermometers
*/
export interface ThermometerBaseProps {
orientation?: 'horizontal' | 'vertical'
isDark?: boolean
label?: string
description?: string
}
/**
* Props for single-selection thermometer
*/
export interface SingleThermometerProps<T extends string> extends ThermometerBaseProps {
options: ThermometerOption<T>[]
value: T
onChange: (value: T) => void
/** Render custom content over an option (e.g., auto-resolve indicator) */
renderOverlay?: (option: ThermometerOption<T>, index: number, isSelected: boolean) => ReactNode
}
/**
* Preview state when hovering over a range thermometer option
*/
export interface RangePreviewState<T extends string> {
/** The previewed min value (what would be selected if clicked) */
previewMin: T
/** The previewed max value (what would be selected if clicked) */
previewMax: T
}
/**
* Props for range (dual-handle) thermometer
*/
export interface RangeThermometerProps<T extends string> extends ThermometerBaseProps {
options: ThermometerOption<T>[]
minValue: T
maxValue: T
onChange: (min: T, max: T) => void
/** Optional counts to show per option */
counts?: Partial<Record<T, number>>
/** Show total count of selected range */
showTotalCount?: boolean
/** Called when hovering over an option to preview what would be selected */
onHoverPreview?: (preview: RangePreviewState<T> | null) => void
}