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:
@@ -1,8 +1,12 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useCallback, useMemo, useRef, useEffect } from 'react'
|
import { useState, useCallback, useMemo, useRef, useEffect } from 'react'
|
||||||
import * as Checkbox from '@radix-ui/react-checkbox'
|
|
||||||
import { css } from '@styled/css'
|
import { css } from '@styled/css'
|
||||||
|
import {
|
||||||
|
RangeThermometer,
|
||||||
|
type ThermometerOption,
|
||||||
|
type RangePreviewState,
|
||||||
|
} from '@/components/Thermometer'
|
||||||
import { useTheme } from '@/contexts/ThemeContext'
|
import { useTheme } from '@/contexts/ThemeContext'
|
||||||
import { MapSelectorMap } from './MapSelectorMap'
|
import { MapSelectorMap } from './MapSelectorMap'
|
||||||
import {
|
import {
|
||||||
@@ -27,6 +31,35 @@ import {
|
|||||||
type ContinentId,
|
type ContinentId,
|
||||||
} from '../continents'
|
} 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:
|
* Selection path for drill-down navigation:
|
||||||
* - [] = World level
|
* - [] = World level
|
||||||
@@ -111,6 +144,9 @@ export function DrillDownMapSelector({
|
|||||||
|
|
||||||
const [path, setPath] = useState<SelectionPath>(getInitialPath)
|
const [path, setPath] = useState<SelectionPath>(getInitialPath)
|
||||||
const [hoveredRegion, setHoveredRegion] = useState<string | null>(null)
|
const [hoveredRegion, setHoveredRegion] = useState<string | null>(null)
|
||||||
|
const [sizeRangePreview, setSizeRangePreview] = useState<RangePreviewState<RegionSize> | null>(
|
||||||
|
null
|
||||||
|
)
|
||||||
const [containerDimensions, setContainerDimensions] = useState({ width: 0, height: 0 })
|
const [containerDimensions, setContainerDimensions] = useState({ width: 0, height: 0 })
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
@@ -276,6 +312,54 @@ export function DrillDownMapSelector({
|
|||||||
return excluded
|
return excluded
|
||||||
}, [currentLevel, path, selectedContinent, includeSizes])
|
}, [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
|
// Compute the label to display for the hovered region
|
||||||
// Shows the next drill-down level name, not the individual region name
|
// Shows the next drill-down level name, not the individual region name
|
||||||
const hoveredLabel = useMemo(() => {
|
const hoveredLabel = useMemo(() => {
|
||||||
@@ -623,6 +707,8 @@ export function DrillDownMapSelector({
|
|||||||
}
|
}
|
||||||
hoverableRegions={currentLevel === 1 ? highlightedRegions : undefined}
|
hoverableRegions={currentLevel === 1 ? highlightedRegions : undefined}
|
||||||
excludedRegions={excludedRegions}
|
excludedRegions={excludedRegions}
|
||||||
|
previewAddRegions={previewAddRegions}
|
||||||
|
previewRemoveRegions={previewRemoveRegions}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Zoom Out Button - positioned inside map, upper right */}
|
{/* 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
|
<div
|
||||||
data-element="region-size-filters"
|
data-element="region-size-filters"
|
||||||
className={css({
|
className={css({
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: '3',
|
top: '3',
|
||||||
right: '3',
|
right: '3',
|
||||||
bottom: '3',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
gap: '1',
|
|
||||||
padding: '2',
|
padding: '2',
|
||||||
bg: isDark ? 'gray.800/90' : 'white/90',
|
bg: isDark ? 'gray.800/90' : 'white/90',
|
||||||
backdropFilter: 'blur(4px)',
|
backdropFilter: 'blur(4px)',
|
||||||
@@ -746,147 +828,17 @@ export function DrillDownMapSelector({
|
|||||||
zIndex: 10,
|
zIndex: 10,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{/* Select All button */}
|
<RangeThermometer
|
||||||
<button
|
options={SIZE_OPTIONS}
|
||||||
data-action="select-all-sizes"
|
minValue={sizesToRange(includeSizes)[0]}
|
||||||
onClick={() => onRegionSizesChange([...ALL_REGION_SIZES])}
|
maxValue={sizesToRange(includeSizes)[1]}
|
||||||
disabled={includeSizes.length === ALL_REGION_SIZES.length}
|
onChange={(min, max) => onRegionSizesChange(rangeToSizes(min, max))}
|
||||||
className={css({
|
orientation="vertical"
|
||||||
display: 'flex',
|
isDark={isDark}
|
||||||
alignItems: 'center',
|
counts={regionCountsBySize as Partial<Record<RegionSize, number>>}
|
||||||
justifyContent: 'center',
|
showTotalCount
|
||||||
gap: '1',
|
onHoverPreview={setSizeRangePreview}
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,74 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useMemo } from 'react'
|
import { useMemo, memo } from 'react'
|
||||||
import { css } from '@styled/css'
|
import { css } from '@styled/css'
|
||||||
|
import { animated, useSpring } from '@react-spring/web'
|
||||||
import { useTheme } from '@/contexts/ThemeContext'
|
import { useTheme } from '@/contexts/ThemeContext'
|
||||||
import type { MapData } from '../types'
|
import type { MapData } from '../types'
|
||||||
import { getRegionColor } from '../mapColors'
|
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 {
|
interface MapSelectorMapProps {
|
||||||
/** Map data to display */
|
/** Map data to display */
|
||||||
mapData: MapData
|
mapData: MapData
|
||||||
@@ -43,6 +106,16 @@ interface MapSelectorMapProps {
|
|||||||
* These will be shown dimmed/grayed out.
|
* These will be shown dimmed/grayed out.
|
||||||
*/
|
*/
|
||||||
excludedRegions?: string[]
|
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({
|
export function MapSelectorMap({
|
||||||
@@ -57,6 +130,8 @@ export function MapSelectorMap({
|
|||||||
selectedGroup,
|
selectedGroup,
|
||||||
hoverableRegions,
|
hoverableRegions,
|
||||||
excludedRegions = [],
|
excludedRegions = [],
|
||||||
|
previewAddRegions = [],
|
||||||
|
previewRemoveRegions = [],
|
||||||
}: MapSelectorMapProps) {
|
}: MapSelectorMapProps) {
|
||||||
const { resolvedTheme } = useTheme()
|
const { resolvedTheme } = useTheme()
|
||||||
const isDark = resolvedTheme === 'dark'
|
const isDark = resolvedTheme === 'dark'
|
||||||
@@ -103,14 +178,36 @@ export function MapSelectorMap({
|
|||||||
return excludedRegions.includes(regionId)
|
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
|
// Get fill color for a region
|
||||||
const getRegionFill = (regionId: string): string => {
|
const getRegionFill = (regionId: string): string => {
|
||||||
const isExcluded = isRegionExcluded(regionId)
|
const isExcluded = isRegionExcluded(regionId)
|
||||||
|
const isPreviewAdd = isRegionPreviewAdd(regionId)
|
||||||
|
const isPreviewRemove = isRegionPreviewRemove(regionId)
|
||||||
const isHovered = isRegionHighlighted(regionId)
|
const isHovered = isRegionHighlighted(regionId)
|
||||||
const isSelected = isRegionSelected(regionId)
|
const isSelected = isRegionSelected(regionId)
|
||||||
const hasSubMap = highlightedRegions.includes(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) {
|
if (isExcluded) {
|
||||||
return isDark ? '#1f2937' : '#e5e7eb' // Gray out excluded regions
|
return isDark ? '#1f2937' : '#e5e7eb' // Gray out excluded regions
|
||||||
}
|
}
|
||||||
@@ -140,10 +237,20 @@ export function MapSelectorMap({
|
|||||||
// Get stroke color for a region
|
// Get stroke color for a region
|
||||||
const getRegionStroke = (regionId: string): string => {
|
const getRegionStroke = (regionId: string): string => {
|
||||||
const isExcluded = isRegionExcluded(regionId)
|
const isExcluded = isRegionExcluded(regionId)
|
||||||
|
const isPreviewAdd = isRegionPreviewAdd(regionId)
|
||||||
|
const isPreviewRemove = isRegionPreviewRemove(regionId)
|
||||||
const isHovered = isRegionHighlighted(regionId)
|
const isHovered = isRegionHighlighted(regionId)
|
||||||
const isSelected = isRegionSelected(regionId)
|
const isSelected = isRegionSelected(regionId)
|
||||||
const hasSubMap = highlightedRegions.includes(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
|
// Excluded regions get subtle stroke
|
||||||
if (isExcluded) {
|
if (isExcluded) {
|
||||||
return isDark ? '#374151' : '#d1d5db'
|
return isDark ? '#374151' : '#d1d5db'
|
||||||
@@ -167,16 +274,31 @@ export function MapSelectorMap({
|
|||||||
|
|
||||||
// Get stroke width for a region
|
// Get stroke width for a region
|
||||||
const getRegionStrokeWidth = (regionId: string): number => {
|
const getRegionStrokeWidth = (regionId: string): number => {
|
||||||
|
const isPreviewAdd = isRegionPreviewAdd(regionId)
|
||||||
|
const isPreviewRemove = isRegionPreviewRemove(regionId)
|
||||||
const isHovered = isRegionHighlighted(regionId)
|
const isHovered = isRegionHighlighted(regionId)
|
||||||
const isSelected = isRegionSelected(regionId)
|
const isSelected = isRegionSelected(regionId)
|
||||||
const hasSubMap = highlightedRegions.includes(regionId)
|
const hasSubMap = highlightedRegions.includes(regionId)
|
||||||
|
|
||||||
|
if (isPreviewAdd || isPreviewRemove) return 1.5
|
||||||
if (isHovered) return 2
|
if (isHovered) return 2
|
||||||
if (isSelected) return 1.5
|
if (isSelected) return 1.5
|
||||||
if (hasSubMap) return 1.5
|
if (hasSubMap) return 1.5
|
||||||
return 0.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 (
|
return (
|
||||||
<div
|
<div
|
||||||
data-component="map-selector-map"
|
data-component="map-selector-map"
|
||||||
@@ -213,30 +335,29 @@ export function MapSelectorMap({
|
|||||||
fill={isDark ? '#111827' : '#e0f2fe'}
|
fill={isDark ? '#111827' : '#e0f2fe'}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Render each region */}
|
{/* Render each region with smooth animations */}
|
||||||
{displayRegions.map((region) => {
|
{displayRegions.map((region) => {
|
||||||
const isExcluded = excludedRegions.includes(region.id)
|
const isExcluded = excludedRegions.includes(region.id)
|
||||||
|
const isPreviewAdd = previewAddRegions.includes(region.id)
|
||||||
|
const isPreviewRemove = previewRemoveRegions.includes(region.id)
|
||||||
return (
|
return (
|
||||||
<path
|
<AnimatedRegion
|
||||||
key={region.id}
|
key={region.id}
|
||||||
data-region={region.id}
|
id={region.id}
|
||||||
data-excluded={isExcluded ? 'true' : undefined}
|
path={region.path}
|
||||||
d={region.path}
|
|
||||||
fill={getRegionFill(region.id)}
|
fill={getRegionFill(region.id)}
|
||||||
stroke={getRegionStroke(region.id)}
|
stroke={getRegionStroke(region.id)}
|
||||||
strokeWidth={getRegionStrokeWidth(region.id)}
|
strokeWidth={getRegionStrokeWidth(region.id)}
|
||||||
|
opacity={getRegionOpacity(region.id)}
|
||||||
|
isExcluded={isExcluded}
|
||||||
|
isPreviewAdd={isPreviewAdd}
|
||||||
|
isPreviewRemove={isPreviewRemove}
|
||||||
onMouseEnter={() => onRegionHover(region.id)}
|
onMouseEnter={() => onRegionHover(region.id)}
|
||||||
onMouseLeave={() => onRegionHover(null)}
|
onMouseLeave={() => onRegionHover(null)}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
onRegionClick(region.id)
|
onRegionClick(region.id)
|
||||||
}}
|
}}
|
||||||
style={{
|
|
||||||
cursor: 'pointer',
|
|
||||||
transition: 'all 0.15s ease',
|
|
||||||
pointerEvents: 'all',
|
|
||||||
opacity: isExcluded ? 0.5 : 1,
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
449
apps/web/src/components/Thermometer/RangeThermometer.tsx
Normal file
449
apps/web/src/components/Thermometer/RangeThermometer.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
124
apps/web/src/components/Thermometer/SingleThermometer.tsx
Normal file
124
apps/web/src/components/Thermometer/SingleThermometer.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
9
apps/web/src/components/Thermometer/index.ts
Normal file
9
apps/web/src/components/Thermometer/index.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export { SingleThermometer } from './SingleThermometer'
|
||||||
|
export { RangeThermometer } from './RangeThermometer'
|
||||||
|
export type {
|
||||||
|
ThermometerOption,
|
||||||
|
ThermometerBaseProps,
|
||||||
|
SingleThermometerProps,
|
||||||
|
RangeThermometerProps,
|
||||||
|
RangePreviewState,
|
||||||
|
} from './types'
|
||||||
58
apps/web/src/components/Thermometer/types.ts
Normal file
58
apps/web/src/components/Thermometer/types.ts
Normal 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user