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:
parent
98e74bae3a
commit
c7c4e7cef3
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
export { SingleThermometer } from './SingleThermometer'
|
||||
export { RangeThermometer } from './RangeThermometer'
|
||||
export type {
|
||||
ThermometerOption,
|
||||
ThermometerBaseProps,
|
||||
SingleThermometerProps,
|
||||
RangeThermometerProps,
|
||||
RangePreviewState,
|
||||
} from './types'
|
||||
|
|
@ -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
|
||||
}
|
||||
Loading…
Reference in New Issue