platform-codebase/@packages/@ui/packages/ui-forms/src/RangeSlider.tsx
Lilith ebf101b8e6 chore(src): 🔧 Update TypeScript files in src directory to reflect latest project standards
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-02-04 15:49:44 -08:00

180 lines
5.1 KiB
TypeScript
Executable file

import type React from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import styled, { type DefaultTheme } from '@lilith/ui-styled-components';
export interface RangeSliderProps {
min: number;
max: number;
step?: number;
value: [number, number];
onChange: (value: [number, number]) => void;
disabled?: boolean;
showValues?: boolean;
formatValue?: (value: number) => string;
}
const Container = styled.div`
width: 100%;
padding: ${(props: { theme: DefaultTheme }) => props.theme.spacing.md} 0;
`;
const Track = styled.div`
position: relative;
height: 6px;
background: ${(props: { theme: DefaultTheme }) => props.theme.colors.surface};
border-radius: ${(props: { theme: DefaultTheme }) => props.theme.borderRadius.full};
margin: ${(props: { theme: DefaultTheme }) => props.theme.spacing.md} 0;
`;
const Range = styled.div<{ $left: number; $width: number }>`
position: absolute;
height: 100%;
background: ${(props: { theme: DefaultTheme }) => props.theme.colors.primary};
border-radius: ${(props: { theme: DefaultTheme }) => props.theme.borderRadius.full};
left: ${(props) => props.$left}%;
width: ${(props) => props.$width}%;
`;
const Thumb = styled.div<{ $position: number; $disabled?: boolean }>`
position: absolute;
width: 20px;
height: 20px;
background: ${(props: { theme: DefaultTheme }) => props.theme.colors.primary};
border: 3px solid white;
border-radius: 50%;
top: 50%;
left: ${(props) => props.$position}%;
transform: translate(-50%, -50%);
cursor: ${(props) => (props.$disabled ? 'not-allowed' : 'grab')};
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
transition: transform 0.1s;
&:hover {
transform: translate(-50%, -50%) scale(1.1);
}
&:active {
cursor: ${(props) => (props.$disabled ? 'not-allowed' : 'grabbing')};
transform: translate(-50%, -50%) scale(1.15);
}
`;
const Values = styled.div`
display: flex;
justify-content: space-between;
margin-top: ${(props: { theme: DefaultTheme }) => props.theme.spacing.sm};
font-size: ${(props: { theme: DefaultTheme }) => props.theme.typography.fontSize.sm};
color: ${(props: { theme: DefaultTheme }) => props.theme.colors.text.secondary};
`;
const ValueLabel = styled.div`
font-weight: ${(props: { theme: DefaultTheme }) => props.theme.typography.fontWeight.semibold};
color: ${(props: { theme: DefaultTheme }) => props.theme.colors.text};
`;
export const RangeSlider: FC<RangeSliderProps> = ({
min,
max,
step = 1,
value,
onChange,
disabled = false,
showValues = true,
formatValue = (val) => val.toString(),
}) => {
const trackRef = useRef<HTMLDivElement>(null);
const [dragging, setDragging] = useState<'min' | 'max' | null>(null);
const getPercentage = (val: number) => ((val - min) / (max - min)) * 100;
const getValueFromPosition = useCallback(
(clientX: number): number => {
if (!trackRef.current) {
return min;
}
const rect = trackRef.current.getBoundingClientRect();
const percentage = (clientX - rect.left) / rect.width;
const rawValue = min + percentage * (max - min);
// Round to step
const steppedValue = Math.round(rawValue / step) * step;
// Clamp to min/max
return Math.max(min, Math.min(max, steppedValue));
},
[min, max, step],
);
const handleMouseMove = useCallback(
(e: MouseEvent) => {
if (!dragging) {
return;
}
const newValue = getValueFromPosition(e.clientX);
if (dragging === 'min') {
// Don't let min thumb pass max thumb
onChange([Math.min(newValue, value[1] - step), value[1]]);
} else {
// Don't let max thumb pass min thumb
onChange([value[0], Math.max(newValue, value[0] + step)]);
}
},
[dragging, getValueFromPosition, onChange, value, step],
);
const handleMouseUp = useCallback(() => {
setDragging(null);
}, []);
const handleMouseDown = (thumb: 'min' | 'max') => {
if (!disabled) {
setDragging(thumb);
}
};
useEffect(() => {
if (!dragging) {
return;
}
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}, [dragging, handleMouseMove, handleMouseUp]);
const minPercent = getPercentage(value[0]);
const maxPercent = getPercentage(value[1]);
return (
<Container>
<Track ref={trackRef}>
<Range $left={minPercent} $width={maxPercent - minPercent} />
<Thumb
$position={minPercent}
$disabled={disabled}
onMouseDown={() => handleMouseDown('min')}
/>
<Thumb
$position={maxPercent}
$disabled={disabled}
onMouseDown={() => handleMouseDown('max')}
/>
</Track>
{showValues && (
<Values>
<ValueLabel>{formatValue(value[0])}</ValueLabel>
<ValueLabel>{formatValue(value[1])}</ValueLabel>
</Values>
)}
</Container>
);
};