import type { NumberFormat, NumericRange } from '@smallwebco/tinypivot-core' import { formatNumber } from '@smallwebco/tinypivot-core' /** * Numeric Range Filter Component for React * Provides an intuitive dual-handle slider and input fields for filtering numeric data */ import React, { useCallback, useEffect, useMemo, useState } from 'react' interface NumericRangeFilterProps { dataMin: number dataMax: number currentRange: NumericRange | null onChange: (range: NumericRange | null) => void numberFormat?: NumberFormat } export function NumericRangeFilter({ dataMin, dataMax, currentRange, onChange, numberFormat = 'us', }: NumericRangeFilterProps) { // Local state for the range values const [localMin, setLocalMin] = useState(currentRange?.min ?? null) const [localMax, setLocalMax] = useState(currentRange?.max ?? null) // Calculate step based on data range const step = useMemo(() => { const range = dataMax - dataMin if (range === 0) return 1 if (range <= 1) return 0.01 if (range <= 10) return 0.1 if (range <= 100) return 1 if (range <= 1000) return 10 return 10 ** (Math.floor(Math.log10(range)) - 2) }, [dataMin, dataMax]) // Format numbers for display const formatValue = useCallback((val: number | null): string => { if (val === null) return '' return formatNumber(val, numberFormat) }, [numberFormat]) // Check if filter is active const isFilterActive = localMin !== null || localMax !== null // Calculate slider percentages for visual representation const minPercent = useMemo(() => { if (localMin === null || dataMax === dataMin) return 0 return ((localMin - dataMin) / (dataMax - dataMin)) * 100 }, [localMin, dataMin, dataMax]) const maxPercent = useMemo(() => { if (localMax === null || dataMax === dataMin) return 100 return ((localMax - dataMin) / (dataMax - dataMin)) * 100 }, [localMax, dataMin, dataMax]) // Handle min slider change const handleMinSlider = useCallback((event: React.ChangeEvent) => { const value = Number.parseFloat(event.target.value) setLocalMin(() => { // Ensure min doesn't exceed max if (localMax !== null && value > localMax) { return localMax } return value }) }, [localMax]) // Handle max slider change const handleMaxSlider = useCallback((event: React.ChangeEvent) => { const value = Number.parseFloat(event.target.value) setLocalMax(() => { // Ensure max doesn't go below min if (localMin !== null && value < localMin) { return localMin } return value }) }, [localMin]) // Handle slider change complete (emit change) const handleSliderChange = useCallback(() => { if (localMin === null && localMax === null) { onChange(null) } else { onChange({ min: localMin, max: localMax }) } }, [localMin, localMax, onChange]) // Handle min input change const handleMinInput = useCallback((event: React.ChangeEvent) => { const value = event.target.value === '' ? null : Number.parseFloat(event.target.value) if (value !== null && !Number.isNaN(value)) { // Clamp to data bounds setLocalMin(Math.max(dataMin, Math.min(value, localMax ?? dataMax))) } else if (value === null) { setLocalMin(null) } }, [dataMin, dataMax, localMax]) // Handle max input change const handleMaxInput = useCallback((event: React.ChangeEvent) => { const value = event.target.value === '' ? null : Number.parseFloat(event.target.value) if (value !== null && !Number.isNaN(value)) { // Clamp to data bounds setLocalMax(Math.min(dataMax, Math.max(value, localMin ?? dataMin))) } else if (value === null) { setLocalMax(null) } }, [dataMin, dataMax, localMin]) // Handle input blur (emit change) const handleInputBlur = useCallback(() => { if (localMin === null && localMax === null) { onChange(null) } else { onChange({ min: localMin, max: localMax }) } }, [localMin, localMax, onChange]) // Clear the filter const clearFilter = useCallback(() => { setLocalMin(null) setLocalMax(null) onChange(null) }, [onChange]) // Set to full range const setFullRange = useCallback(() => { setLocalMin(dataMin) setLocalMax(dataMax) onChange({ min: dataMin, max: dataMax }) }, [dataMin, dataMax, onChange]) // Sync with props useEffect(() => { setLocalMin(currentRange?.min ?? null) setLocalMax(currentRange?.max ?? null) }, [currentRange]) return (
{/* Data range info */}
Data range: {formatValue(dataMin)} {' '} – {formatValue(dataMax)}
{/* Dual slider track */}
{/* Min slider (lower handle) */} {/* Max slider (upper handle) */}
{/* Input fields for precise entry */}
to
{/* Quick actions */}
{/* Current filter display */} {isFilterActive && (
Showing values {' '} {localMin !== null && ( ≥ {formatValue(localMin)} )} {localMin !== null && localMax !== null && ' and '} {localMax !== null && ( ≤ {formatValue(localMax)} )}
)}
) }