import React, { useCallback, useEffect, useState } from 'react'; import { LayoutChangeEvent, StyleSheet, Text, View } from 'react-native'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import Animated, { runOnJS, useAnimatedStyle, useSharedValue, } from 'react-native-reanimated'; import { m3Colors, m3Shape, m3Type, spacing } from '../theme/proulr-theme'; export type RangeSliderProps = { min: number; max: number; step?: number; value: [number, number]; onChange: (value: [number, number]) => void; formatLabel?: (n: number) => string; }; const THUMB_SIZE = 28; const TRACK_HEIGHT = 4; function clamp(value: number, lo: number, hi: number): number { 'worklet'; return Math.min(hi, Math.max(lo, value)); } /** * Generic double-thumb range slider (reanimated + gesture-handler). * Fully theme-driven — no product logic. Emits stepped `[low, high]` values. */ export function RangeSlider({ min, max, step = 1, value, onChange, formatLabel, }: RangeSliderProps) { const [trackWidth, setTrackWidth] = useState(0); const [labels, setLabels] = useState<[number, number]>(value); const usable = Math.max(0, trackWidth - THUMB_SIZE); const span = max - min || 1; const lowX = useSharedValue(0); const highX = useSharedValue(0); const lowStart = useSharedValue(0); const highStart = useSharedValue(0); const valueToX = useCallback( (v: number) => ((clampJs(v, min, max) - min) / span) * usable, [min, max, span, usable], ); // Sync thumbs when external value or track width changes. useEffect(() => { lowX.value = valueToX(value[0]); highX.value = valueToX(value[1]); setLabels(value); }, [value, valueToX, lowX, highX]); const emit = useCallback( (low: number, high: number) => { setLabels([low, high]); onChange([low, high]); }, [onChange], ); const updateLabels = useCallback((low: number, high: number) => { setLabels([low, high]); }, []); const lowGesture = Gesture.Pan() .onBegin(() => { lowStart.value = lowX.value; }) .onUpdate((event) => { const next = clamp(lowStart.value + event.translationX, 0, highX.value); lowX.value = next; runOnJS(updateLabels)(jsXToValue(next, min, max, span, step, usable), jsXToValue(highX.value, min, max, span, step, usable)); }) .onEnd(() => { runOnJS(emit)( jsXToValue(lowX.value, min, max, span, step, usable), jsXToValue(highX.value, min, max, span, step, usable), ); }); const highGesture = Gesture.Pan() .onBegin(() => { highStart.value = highX.value; }) .onUpdate((event) => { const next = clamp(highStart.value + event.translationX, lowX.value, usable); highX.value = next; runOnJS(updateLabels)(jsXToValue(lowX.value, min, max, span, step, usable), jsXToValue(next, min, max, span, step, usable)); }) .onEnd(() => { runOnJS(emit)( jsXToValue(lowX.value, min, max, span, step, usable), jsXToValue(highX.value, min, max, span, step, usable), ); }); const lowThumbStyle = useAnimatedStyle(() => ({ transform: [{ translateX: lowX.value }], })); const highThumbStyle = useAnimatedStyle(() => ({ transform: [{ translateX: highX.value }], })); const activeTrackStyle = useAnimatedStyle(() => ({ left: lowX.value + THUMB_SIZE / 2, width: Math.max(0, highX.value - lowX.value), })); const onTrackLayout = useCallback((event: LayoutChangeEvent) => { setTrackWidth(event.nativeEvent.layout.width); }, []); const format = formatLabel ?? ((n: number) => String(n)); return ( {format(labels[0])} {format(labels[1])} ); } function clampJs(value: number, lo: number, hi: number): number { return Math.min(hi, Math.max(lo, value)); } function jsXToValue( x: number, min: number, max: number, span: number, step: number, usable: number, ): number { const raw = min + (x / (usable || 1)) * span; const stepped = Math.round(raw / step) * step; return clampJs(stepped, min, max); } const styles = StyleSheet.create({ container: { gap: spacing.sm, paddingVertical: spacing.xs, }, labelsRow: { flexDirection: 'row', justifyContent: 'space-between', }, label: { color: m3Colors.onSurface, fontFamily: m3Type.label.fontFamily, fontSize: m3Type.label.fontSize, }, trackArea: { height: THUMB_SIZE, justifyContent: 'center', }, trackBase: { height: TRACK_HEIGHT, borderRadius: m3Shape.cornerFull, backgroundColor: m3Colors.surfaceContainerHighest, }, trackActive: { position: 'absolute', height: TRACK_HEIGHT, borderRadius: m3Shape.cornerFull, backgroundColor: m3Colors.primary, }, thumb: { position: 'absolute', left: 0, width: THUMB_SIZE, height: THUMB_SIZE, borderRadius: m3Shape.cornerFull, backgroundColor: m3Colors.primary, borderWidth: 3, borderColor: m3Colors.onPrimary, }, });