import React, { useState, useRef, useCallback, useEffect } from 'react'; export interface SliderProps { /** Current value (controlled) */ value?: number; /** Default value */ defaultValue?: number; /** Minimum value */ min?: number; /** Maximum value */ max?: number; /** Step increment */ step?: number; /** Callback when value changes */ onChange?: (value: number) => void; /** Callback when sliding ends */ onChangeEnd?: (value: number) => void; /** Show current value tooltip */ showTooltip?: boolean | 'always' | 'hover' | 'drag'; /** Format tooltip value */ formatTooltip?: (value: number) => string; /** Show tick marks */ showTicks?: boolean; /** Custom tick values */ ticks?: number[]; /** Show tick labels */ showTickLabels?: boolean; /** Format tick labels */ formatTickLabel?: (value: number) => string; /** Color variant */ variant?: 'primary' | 'secondary' | 'success' | 'danger' | 'warning' | 'info'; /** Size variant */ size?: 'sm' | 'md' | 'lg'; /** Disabled state */ disabled?: boolean; /** Orientation */ orientation?: 'horizontal' | 'vertical'; /** Additional CSS classes */ className?: string; } export const Slider: React.FC = ({ value: controlledValue, defaultValue = 0, min = 0, max = 100, step = 1, onChange, onChangeEnd, showTooltip = 'drag', formatTooltip = (v) => String(v), showTicks = false, ticks, showTickLabels = false, formatTickLabel = (v) => String(v), variant = 'primary', size = 'md', disabled = false, orientation = 'horizontal', className = '', }) => { const [internalValue, setInternalValue] = useState(defaultValue); const [isDragging, setIsDragging] = useState(false); const [isHovering, setIsHovering] = useState(false); const trackRef = useRef(null); const value = controlledValue ?? internalValue; // Calculate percentage const percentage = ((value - min) / (max - min)) * 100; // Get value from position const getValueFromPosition = useCallback((clientX: number, clientY: number): number => { if (!trackRef.current) return value; const rect = trackRef.current.getBoundingClientRect(); let ratio: number; if (orientation === 'horizontal') { ratio = (clientX - rect.left) / rect.width; } else { ratio = 1 - (clientY - rect.top) / rect.height; } ratio = Math.max(0, Math.min(1, ratio)); let newValue = min + ratio * (max - min); // Snap to step newValue = Math.round(newValue / step) * step; newValue = Math.max(min, Math.min(max, newValue)); return newValue; }, [min, max, step, value, orientation]); // Handle mouse/touch move const handleMove = useCallback((clientX: number, clientY: number) => { if (disabled) return; const newValue = getValueFromPosition(clientX, clientY); if (controlledValue === undefined) { setInternalValue(newValue); } onChange?.(newValue); }, [disabled, getValueFromPosition, controlledValue, onChange]); // Handle mouse down const handleMouseDown = (e: React.MouseEvent) => { if (disabled) return; e.preventDefault(); setIsDragging(true); handleMove(e.clientX, e.clientY); }; // Handle touch start const handleTouchStart = (e: React.TouchEvent) => { if (disabled) return; setIsDragging(true); const touch = e.touches[0]; handleMove(touch.clientX, touch.clientY); }; // Handle mouse/touch events useEffect(() => { if (!isDragging) return; const handleMouseMove = (e: MouseEvent) => { handleMove(e.clientX, e.clientY); }; const handleTouchMove = (e: TouchEvent) => { const touch = e.touches[0]; handleMove(touch.clientX, touch.clientY); }; const handleEnd = () => { setIsDragging(false); onChangeEnd?.(value); }; document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleEnd); document.addEventListener('touchmove', handleTouchMove); document.addEventListener('touchend', handleEnd); return () => { document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleEnd); document.removeEventListener('touchmove', handleTouchMove); document.removeEventListener('touchend', handleEnd); }; }, [isDragging, handleMove, onChangeEnd, value]); // Handle keyboard const handleKeyDown = (e: React.KeyboardEvent) => { if (disabled) return; let newValue = value; const largeStep = step * 10; switch (e.key) { case 'ArrowRight': case 'ArrowUp': newValue = Math.min(max, value + step); break; case 'ArrowLeft': case 'ArrowDown': newValue = Math.max(min, value - step); break; case 'PageUp': newValue = Math.min(max, value + largeStep); break; case 'PageDown': newValue = Math.max(min, value - largeStep); break; case 'Home': newValue = min; break; case 'End': newValue = max; break; default: return; } e.preventDefault(); if (controlledValue === undefined) { setInternalValue(newValue); } onChange?.(newValue); onChangeEnd?.(newValue); }; // Generate tick marks const tickMarks = ticks || (showTicks ? Array.from({ length: Math.floor((max - min) / step) + 1 }, (_, i) => min + i * step) : [] ); // Determine tooltip visibility const shouldShowTooltip = showTooltip === 'always' || (showTooltip === 'hover' && (isHovering || isDragging)) || (showTooltip === 'drag' && isDragging) || (showTooltip === true && (isHovering || isDragging)); const classes = [ 'll-slider', `ll-slider-${orientation}`, `ll-slider-${variant}`, `ll-slider-${size}`, disabled && 'll-slider-disabled', className, ].filter(Boolean).join(' '); const trackStyle = orientation === 'horizontal' ? { width: `${percentage}%` } : { height: `${percentage}%` }; const thumbStyle = orientation === 'horizontal' ? { left: `${percentage}%` } : { bottom: `${percentage}%` }; return (
setIsHovering(true)} onMouseLeave={() => setIsHovering(false)} >
{/* Tick marks */} {tickMarks.length > 0 && (
{tickMarks.map((tick) => { const tickPercent = ((tick - min) / (max - min)) * 100; const tickStyle = orientation === 'horizontal' ? { left: `${tickPercent}%` } : { bottom: `${tickPercent}%` }; return (
{showTickLabels && (
{formatTickLabel(tick)}
)}
); })}
)} {/* Thumb */}
{shouldShowTooltip && (
{formatTooltip(value)}
)}
); }; // Range Slider export interface RangeSliderProps extends Omit { /** Current values [min, max] (controlled) */ value?: [number, number]; /** Default values [min, max] */ defaultValue?: [number, number]; /** Callback when values change */ onChange?: (value: [number, number]) => void; /** Callback when sliding ends */ onChangeEnd?: (value: [number, number]) => void; /** Minimum gap between handles */ minGap?: number; } export const RangeSlider: React.FC = ({ value: controlledValue, defaultValue = [25, 75], min = 0, max = 100, step = 1, onChange, onChangeEnd, minGap = 0, showTooltip = 'drag', formatTooltip = (v) => String(v), showTicks = false, ticks, showTickLabels = false, formatTickLabel = (v) => String(v), variant = 'primary', size = 'md', disabled = false, orientation = 'horizontal', className = '', }) => { const [internalValue, setInternalValue] = useState(defaultValue); const [activeHandle, setActiveHandle] = useState<0 | 1 | null>(null); const [isHovering, setIsHovering] = useState<0 | 1 | null>(null); const trackRef = useRef(null); const value = controlledValue ?? internalValue; // Calculate percentages const lowPercent = ((value[0] - min) / (max - min)) * 100; const highPercent = ((value[1] - min) / (max - min)) * 100; // Get value from position const getValueFromPosition = useCallback((clientX: number, clientY: number): number => { if (!trackRef.current) return 0; const rect = trackRef.current.getBoundingClientRect(); let ratio: number; if (orientation === 'horizontal') { ratio = (clientX - rect.left) / rect.width; } else { ratio = 1 - (clientY - rect.top) / rect.height; } ratio = Math.max(0, Math.min(1, ratio)); let newValue = min + ratio * (max - min); newValue = Math.round(newValue / step) * step; return newValue; }, [min, max, step, orientation]); // Handle move const handleMove = useCallback((clientX: number, clientY: number) => { if (disabled || activeHandle === null) return; const newValue = getValueFromPosition(clientX, clientY); let newValues: [number, number] = [...value]; if (activeHandle === 0) { newValues[0] = Math.min(newValue, value[1] - minGap); newValues[0] = Math.max(min, newValues[0]); } else { newValues[1] = Math.max(newValue, value[0] + minGap); newValues[1] = Math.min(max, newValues[1]); } if (controlledValue === undefined) { setInternalValue(newValues); } onChange?.(newValues); }, [disabled, activeHandle, getValueFromPosition, value, minGap, min, max, controlledValue, onChange]); // Handle track click const handleTrackClick = (e: React.MouseEvent) => { if (disabled) return; const clickValue = getValueFromPosition(e.clientX, e.clientY); const distToLow = Math.abs(clickValue - value[0]); const distToHigh = Math.abs(clickValue - value[1]); const handle = distToLow <= distToHigh ? 0 : 1; setActiveHandle(handle); let newValues: [number, number] = [...value]; if (handle === 0) { newValues[0] = Math.min(clickValue, value[1] - minGap); newValues[0] = Math.max(min, newValues[0]); } else { newValues[1] = Math.max(clickValue, value[0] + minGap); newValues[1] = Math.min(max, newValues[1]); } if (controlledValue === undefined) { setInternalValue(newValues); } onChange?.(newValues); }; // Handle mouse/touch events useEffect(() => { if (activeHandle === null) return; const handleMouseMove = (e: MouseEvent) => handleMove(e.clientX, e.clientY); const handleTouchMove = (e: TouchEvent) => handleMove(e.touches[0].clientX, e.touches[0].clientY); const handleEnd = () => { setActiveHandle(null); onChangeEnd?.(value); }; document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleEnd); document.addEventListener('touchmove', handleTouchMove); document.addEventListener('touchend', handleEnd); return () => { document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleEnd); document.removeEventListener('touchmove', handleTouchMove); document.removeEventListener('touchend', handleEnd); }; }, [activeHandle, handleMove, onChangeEnd, value]); // Tick marks const tickMarks = ticks || (showTicks ? Array.from({ length: Math.floor((max - min) / step) + 1 }, (_, i) => min + i * step) : [] ); const classes = [ 'll-slider', 'll-range-slider', `ll-slider-${orientation}`, `ll-slider-${variant}`, `ll-slider-${size}`, disabled && 'll-slider-disabled', className, ].filter(Boolean).join(' '); const fillStyle = orientation === 'horizontal' ? { left: `${lowPercent}%`, width: `${highPercent - lowPercent}%` } : { bottom: `${lowPercent}%`, height: `${highPercent - lowPercent}%` }; const thumbStyles = [ orientation === 'horizontal' ? { left: `${lowPercent}%` } : { bottom: `${lowPercent}%` }, orientation === 'horizontal' ? { left: `${highPercent}%` } : { bottom: `${highPercent}%` }, ]; const shouldShowTooltip = (handle: 0 | 1) => showTooltip === 'always' || (showTooltip === 'hover' && (isHovering === handle || activeHandle === handle)) || (showTooltip === 'drag' && activeHandle === handle) || (showTooltip === true && (isHovering === handle || activeHandle === handle)); return (
{/* Tick marks */} {tickMarks.length > 0 && (
{tickMarks.map((tick) => { const tickPercent = ((tick - min) / (max - min)) * 100; const tickStyle = orientation === 'horizontal' ? { left: `${tickPercent}%` } : { bottom: `${tickPercent}%` }; return (
{showTickLabels && (
{formatTickLabel(tick)}
)}
); })}
)} {/* Thumbs */} {[0, 1].map((handle) => (
{ e.stopPropagation(); setActiveHandle(handle as 0 | 1); }} onTouchStart={() => setActiveHandle(handle as 0 | 1)} onMouseEnter={() => setIsHovering(handle as 0 | 1)} onMouseLeave={() => setIsHovering(null)} > {shouldShowTooltip(handle as 0 | 1) && (
{formatTooltip(value[handle as 0 | 1])}
)}
))}
); };