related-ui-components 3.2.9 → 3.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import React, { useCallback, useState } from "react";
1
+ import React, { useCallback, useMemo, useState } from "react";
2
2
  import { I18nManager, StyleSheet, View } from "react-native";
3
3
  import { Gesture, GestureDetector } from "react-native-gesture-handler";
4
4
  import Animated, {
@@ -6,13 +6,17 @@ import Animated, {
6
6
  useAnimatedStyle,
7
7
  useSharedValue,
8
8
  } from "react-native-reanimated";
9
- import { SliderLabels } from "./SliderLabel";
10
9
  import { ThemeType, useTheme } from "../../theme";
10
+ import { SliderLabels } from "./SliderLabel";
11
+ import { Input } from "../Input";
11
12
 
12
13
  const THUMB_SIZE = 28;
14
+ const THUMB_RADIUS = THUMB_SIZE / 2;
13
15
  const RAIL_HEIGHT = 6;
14
16
  const LABEL_HEIGHT = 26;
15
17
 
18
+ type ScaleType = "linear" | "logarithmic" | "percentile";
19
+
16
20
  interface RangeSliderProps {
17
21
  min: number;
18
22
  max: number;
@@ -21,8 +25,64 @@ interface RangeSliderProps {
21
25
  initialMinValue: number;
22
26
  initialMaxValue: number;
23
27
  onValueChange: (values: { min: number; max: number }) => void;
28
+ theme?: ThemeType;
29
+ scale?: ScaleType;
30
+ dataPoints?: number[];
31
+ showCustomInputs?: boolean;
32
+ inputPlaceholders?: {
33
+ min?: string;
34
+ max?: string;
35
+ };
36
+ inputLabels?: {
37
+ min?: string;
38
+ max?: string;
39
+ };
40
+ isBottomSheet?: boolean;
41
+ }
42
+
43
+ /**
44
+ * Creates percentile breakpoints from data
45
+ * Returns sorted unique values that represent the distribution
46
+ */
47
+ function createPercentileBreakpoints(data: number[]): number[] {
48
+ const sorted = [...new Set(data)].sort((a, b) => a - b);
49
+
50
+ if (sorted.length <= 100) {
51
+ return sorted;
52
+ }
53
+
54
+ const breakpoints: number[] = [];
55
+ const numBreakpoints = 100;
24
56
 
25
- theme?: ThemeType
57
+ for (let i = 0; i <= numBreakpoints; i++) {
58
+ const index = Math.floor((i / numBreakpoints) * (sorted.length - 1));
59
+ const value = sorted[index];
60
+ if (breakpoints[breakpoints.length - 1] !== value) {
61
+ breakpoints.push(value);
62
+ }
63
+ }
64
+
65
+ return breakpoints;
66
+ }
67
+
68
+ /**
69
+ * Binary search to find position in breakpoints
70
+ */
71
+ function findBreakpointIndex(breakpoints: number[], value: number): number {
72
+ "worklet";
73
+ let left = 0;
74
+ let right = breakpoints.length - 1;
75
+
76
+ while (left < right) {
77
+ const mid = Math.floor((left + right) / 2);
78
+ if (breakpoints[mid] < value) {
79
+ left = mid + 1;
80
+ } else {
81
+ right = mid;
82
+ }
83
+ }
84
+
85
+ return left;
26
86
  }
27
87
 
28
88
  const RangeSlider: React.FC<RangeSliderProps> = ({
@@ -33,45 +93,125 @@ const RangeSlider: React.FC<RangeSliderProps> = ({
33
93
  initialMinValue,
34
94
  initialMaxValue,
35
95
  onValueChange,
36
- theme
96
+ theme,
97
+ scale = "linear",
98
+ dataPoints,
99
+ showCustomInputs = false,
100
+ inputPlaceholders,
101
+ inputLabels,
102
+ isBottomSheet = false,
37
103
  }) => {
38
- const { theme: defaultTheme} = useTheme();
39
-
104
+ const { theme: defaultTheme } = useTheme();
40
105
  const currTheme = theme || defaultTheme;
41
-
42
106
  const styles = getStyles(currTheme);
43
-
44
107
  const isRTL = I18nManager.isRTL;
45
108
 
46
- // State for label text values, passed down to the SliderLabels component
47
109
  const [leftLabel, setLeftLabel] = useState(initialMinValue.toLocaleString());
48
110
  const [rightLabel, setRightLabel] = useState(
49
- initialMaxValue.toLocaleString()
111
+ initialMaxValue.toLocaleString(),
112
+ );
113
+
114
+ // State for custom input fields
115
+ const [minInputValue, setMinInputValue] = useState(
116
+ initialMinValue.toString(),
117
+ );
118
+ const [maxInputValue, setMaxInputValue] = useState(
119
+ initialMaxValue.toString(),
50
120
  );
51
121
 
122
+ // The effective track width (where thumb CENTER can travel)
123
+ const effectiveWidth = sliderWidth - THUMB_SIZE;
124
+
125
+ // Pre-compute breakpoints for percentile scale
126
+ const breakpoints = useMemo(() => {
127
+ if (scale === "percentile" && dataPoints?.length) {
128
+ return createPercentileBreakpoints(dataPoints);
129
+ }
130
+ return [];
131
+ }, [scale, dataPoints]);
132
+
133
+ const safeMin = scale === "logarithmic" ? Math.max(min, 0.001) : min;
134
+
52
135
  const valueToPosition = useCallback(
53
136
  (value: number) => {
54
137
  "worklet";
55
- // 3. Invert position calculation for RTL
56
- const percentage = (value - min) / (max - min);
57
- return isRTL
58
- ? (1 - percentage) * sliderWidth
59
- : percentage * sliderWidth;
138
+ let percentage: number;
139
+
140
+ if (scale === "percentile" && breakpoints.length > 1) {
141
+ const index = findBreakpointIndex(breakpoints, value);
142
+
143
+ if (index === 0) {
144
+ percentage = 0;
145
+ } else if (index >= breakpoints.length - 1) {
146
+ percentage = 1;
147
+ } else {
148
+ const lowerVal = breakpoints[index - 1];
149
+ const upperVal = breakpoints[index];
150
+ const lowerPct = (index - 1) / (breakpoints.length - 1);
151
+ const upperPct = index / (breakpoints.length - 1);
152
+
153
+ if (upperVal === lowerVal) {
154
+ percentage = lowerPct;
155
+ } else {
156
+ const ratio = (value - lowerVal) / (upperVal - lowerVal);
157
+ percentage = lowerPct + ratio * (upperPct - lowerPct);
158
+ }
159
+ }
160
+ } else if (scale === "logarithmic") {
161
+ const logMin = Math.log(safeMin);
162
+ const logMax = Math.log(max);
163
+ const logValue = Math.log(Math.max(value, safeMin));
164
+ percentage = (logValue - logMin) / (logMax - logMin);
165
+ } else {
166
+ percentage = (value - min) / (max - min);
167
+ }
168
+
169
+ // Map to effective width, offset by thumb radius
170
+ const position = percentage * effectiveWidth + THUMB_RADIUS;
171
+ return isRTL ? sliderWidth - position : position;
60
172
  },
61
- [min, max, sliderWidth, isRTL]
173
+ [min, max, safeMin, sliderWidth, effectiveWidth, isRTL, scale, breakpoints],
62
174
  );
63
175
 
64
176
  const positionToValue = useCallback(
65
177
  (position: number) => {
66
178
  "worklet";
67
- // 4. Invert value calculation for RTL
68
- const percentage = position / sliderWidth;
69
- const rawValue = isRTL
70
- ? (1 - percentage) * (max - min) + min
71
- : percentage * (max - min) + min;
179
+ // Convert position to percentage using effective width
180
+ const adjustedPosition = isRTL ? sliderWidth - position : position;
181
+ const percentage = (adjustedPosition - THUMB_RADIUS) / effectiveWidth;
182
+ const clampedPercentage = Math.max(0, Math.min(1, percentage));
183
+
184
+ let rawValue: number;
185
+
186
+ if (scale === "percentile" && breakpoints.length > 1) {
187
+ const exactIndex = clampedPercentage * (breakpoints.length - 1);
188
+ const lowerIndex = Math.floor(exactIndex);
189
+ const upperIndex = Math.min(lowerIndex + 1, breakpoints.length - 1);
190
+ const ratio = exactIndex - lowerIndex;
191
+ rawValue =
192
+ breakpoints[lowerIndex] +
193
+ ratio * (breakpoints[upperIndex] - breakpoints[lowerIndex]);
194
+ } else if (scale === "logarithmic") {
195
+ const logMin = Math.log(safeMin);
196
+ const logMax = Math.log(max);
197
+ rawValue = Math.exp(logMin + clampedPercentage * (logMax - logMin));
198
+ } else {
199
+ rawValue = clampedPercentage * (max - min) + min;
200
+ }
201
+
72
202
  return Math.round(rawValue / step) * step;
73
203
  },
74
- [min, max, step, sliderWidth, isRTL]
204
+ [
205
+ min,
206
+ max,
207
+ safeMin,
208
+ step,
209
+ sliderWidth,
210
+ effectiveWidth,
211
+ isRTL,
212
+ scale,
213
+ breakpoints,
214
+ ],
75
215
  );
76
216
 
77
217
  const leftPosition = useSharedValue(valueToPosition(initialMinValue));
@@ -95,25 +235,30 @@ const RangeSlider: React.FC<RangeSliderProps> = ({
95
235
  if (activeThumb.value === null) return;
96
236
  const newPos = context.value.x + e.translationX;
97
237
 
98
- // 5. Adjust clamping logic for RTL
99
238
  if (activeThumb.value === "left") {
100
- const lowerBound = isRTL ? rightPosition.value + THUMB_SIZE : 0;
101
- const upperBound = isRTL ? sliderWidth : rightPosition.value - THUMB_SIZE;
102
- const clampedPos = Math.max(
103
- Math.min(newPos, upperBound),
104
- lowerBound
105
- );
239
+ const lowerBound = isRTL
240
+ ? rightPosition.value + THUMB_SIZE
241
+ : THUMB_RADIUS;
242
+ const upperBound = isRTL
243
+ ? sliderWidth - THUMB_RADIUS
244
+ : rightPosition.value - THUMB_SIZE;
245
+ const clampedPos = Math.max(Math.min(newPos, upperBound), lowerBound);
106
246
  leftPosition.value = clampedPos;
107
- runOnJS(setLeftLabel)(positionToValue(clampedPos).toLocaleString());
247
+ const newValue = positionToValue(clampedPos);
248
+ runOnJS(setLeftLabel)(newValue.toLocaleString());
249
+ runOnJS(setMinInputValue)(newValue.toString());
108
250
  } else {
109
- const lowerBound = isRTL ? 0 : leftPosition.value + THUMB_SIZE;
110
- const upperBound = isRTL ? leftPosition.value - THUMB_SIZE : sliderWidth;
111
- const clampedPos = Math.max(
112
- Math.min(newPos, upperBound),
113
- lowerBound
114
- );
251
+ const lowerBound = isRTL
252
+ ? THUMB_RADIUS
253
+ : leftPosition.value + THUMB_SIZE;
254
+ const upperBound = isRTL
255
+ ? leftPosition.value - THUMB_SIZE
256
+ : sliderWidth - THUMB_RADIUS;
257
+ const clampedPos = Math.max(Math.min(newPos, upperBound), lowerBound);
115
258
  rightPosition.value = clampedPos;
116
- runOnJS(setRightLabel)(positionToValue(clampedPos).toLocaleString());
259
+ const newValue = positionToValue(clampedPos);
260
+ runOnJS(setRightLabel)(newValue.toLocaleString());
261
+ runOnJS(setMaxInputValue)(newValue.toString());
117
262
  }
118
263
  })
119
264
  .onEnd(() => {
@@ -124,17 +269,102 @@ const RangeSlider: React.FC<RangeSliderProps> = ({
124
269
  activeThumb.value = null;
125
270
  });
126
271
 
272
+ // Handlers for custom input fields
273
+ const handleMinInputChange = useCallback((text: string) => {
274
+ // Allow only numeric input (with optional decimal)
275
+ const sanitized = text.replace(/[^0-9.]/g, "");
276
+ setMinInputValue(sanitized);
277
+ }, []);
278
+
279
+ const handleMaxInputChange = useCallback((text: string) => {
280
+ const sanitized = text.replace(/[^0-9.]/g, "");
281
+ setMaxInputValue(sanitized);
282
+ }, []);
283
+
284
+ const handleMinInputSubmit = useCallback(() => {
285
+ const parsed = parseFloat(minInputValue);
286
+ if (isNaN(parsed)) {
287
+ // Reset to current slider value
288
+ const currentValue = positionToValue(leftPosition.value);
289
+ setMinInputValue(currentValue.toString());
290
+ return;
291
+ }
292
+
293
+ // Clamp value: must be >= min and <= current max value
294
+ const currentMaxValue = positionToValue(rightPosition.value);
295
+ const clampedValue = Math.max(
296
+ min,
297
+ Math.min(parsed, currentMaxValue - step),
298
+ );
299
+ const steppedValue = Math.round(clampedValue / step) * step;
300
+
301
+ // Update input display
302
+ setMinInputValue(steppedValue.toString());
303
+ setLeftLabel(steppedValue.toLocaleString());
304
+
305
+ // Update slider position
306
+ leftPosition.value = valueToPosition(steppedValue);
307
+
308
+ // Trigger callback
309
+ onValueChange({ min: steppedValue, max: currentMaxValue });
310
+ }, [
311
+ minInputValue,
312
+ min,
313
+ step,
314
+ leftPosition,
315
+ rightPosition,
316
+ positionToValue,
317
+ valueToPosition,
318
+ onValueChange,
319
+ ]);
127
320
 
321
+ const handleMaxInputSubmit = useCallback(() => {
322
+ const parsed = parseFloat(maxInputValue);
323
+ if (isNaN(parsed)) {
324
+ // Reset to current slider value
325
+ const currentValue = positionToValue(rightPosition.value);
326
+ setMaxInputValue(currentValue.toString());
327
+ return;
328
+ }
329
+
330
+ // Clamp value: must be <= max and >= current min value
331
+ const currentMinValue = positionToValue(leftPosition.value);
332
+ const clampedValue = Math.min(
333
+ max,
334
+ Math.max(parsed, currentMinValue + step),
335
+ );
336
+ const steppedValue = Math.round(clampedValue / step) * step;
337
+
338
+ // Update input display
339
+ setMaxInputValue(steppedValue.toString());
340
+ setRightLabel(steppedValue.toLocaleString());
341
+
342
+ // Update slider position
343
+ rightPosition.value = valueToPosition(steppedValue);
344
+
345
+ // Trigger callback
346
+ onValueChange({ min: currentMinValue, max: steppedValue });
347
+ }, [
348
+ maxInputValue,
349
+ max,
350
+ step,
351
+ leftPosition,
352
+ rightPosition,
353
+ positionToValue,
354
+ valueToPosition,
355
+ onValueChange,
356
+ ]);
357
+
358
+ // Thumb position = center of thumb, so offset by radius to get left edge
128
359
  const animatedLeftThumbStyle = useAnimatedStyle(() => ({
129
- transform: [{ translateX: leftPosition.value - (isRTL ? THUMB_SIZE : 0)}],
360
+ transform: [{ translateX: leftPosition.value - THUMB_RADIUS }],
130
361
  }));
131
362
 
132
363
  const animatedRightThumbStyle = useAnimatedStyle(() => ({
133
- transform: [{ translateX: rightPosition.value - (isRTL ? 0 : THUMB_SIZE) }],
364
+ transform: [{ translateX: rightPosition.value - THUMB_RADIUS }],
134
365
  }));
135
366
 
136
367
  const animatedActiveRailStyle = useAnimatedStyle(() => {
137
- // 6. Use Math.min and Math.max to correctly draw the active rail in both LTR and RTL
138
368
  const start = Math.min(leftPosition.value, rightPosition.value);
139
369
  const end = Math.max(leftPosition.value, rightPosition.value);
140
370
  return {
@@ -144,15 +374,53 @@ const RangeSlider: React.FC<RangeSliderProps> = ({
144
374
  });
145
375
 
146
376
  return (
147
- <View style={[styles.container, { width: sliderWidth }]}>
148
- <SliderLabels
149
- leftValue={leftLabel}
150
- rightValue={rightLabel}
151
- leftPosition={leftPosition}
152
- rightPosition={rightPosition}
153
- sliderWidth={sliderWidth}
154
- thumbSize={THUMB_SIZE}
155
- />
377
+ <View
378
+ style={[
379
+ showCustomInputs ? styles.containerWithInputs : styles.container,
380
+ { width: sliderWidth },
381
+ ]}
382
+ >
383
+ {showCustomInputs ? (
384
+ <View style={styles.inputsContainer}>
385
+ <View style={styles.inputWrapper}>
386
+ <Input
387
+ label={inputLabels?.min ?? "Min"}
388
+ value={minInputValue}
389
+ onChangeText={handleMinInputChange}
390
+ onBlur={handleMinInputSubmit}
391
+ onSubmitEditing={handleMinInputSubmit}
392
+ keyboardType="numeric"
393
+ placeholder={inputPlaceholders?.min}
394
+ placeholderTextColor={currTheme.surfaceVariant}
395
+ returnKeyType="done"
396
+ isBottomSheet={isBottomSheet}
397
+ />
398
+ </View>
399
+ <View style={styles.inputWrapper}>
400
+ <Input
401
+ label={inputLabels?.max ?? "Max"}
402
+ value={maxInputValue}
403
+ onChangeText={handleMaxInputChange}
404
+ onBlur={handleMaxInputSubmit}
405
+ onSubmitEditing={handleMaxInputSubmit}
406
+ keyboardType="numeric"
407
+ placeholder={inputPlaceholders?.max}
408
+ placeholderTextColor={currTheme.surfaceVariant}
409
+ returnKeyType="done"
410
+ isBottomSheet={isBottomSheet}
411
+ />
412
+ </View>
413
+ </View>
414
+ ) : (
415
+ <SliderLabels
416
+ leftValue={leftLabel}
417
+ rightValue={rightLabel}
418
+ leftPosition={leftPosition}
419
+ rightPosition={rightPosition}
420
+ sliderWidth={sliderWidth}
421
+ thumbSize={THUMB_SIZE}
422
+ />
423
+ )}
156
424
 
157
425
  <GestureDetector gesture={panGesture}>
158
426
  <View style={styles.railContainer}>
@@ -182,7 +450,10 @@ const getStyles = (theme: ThemeType) =>
182
450
  height: LABEL_HEIGHT + THUMB_SIZE,
183
451
  justifyContent: "center",
184
452
  marginTop: LABEL_HEIGHT,
185
- direction: "ltr"
453
+ direction: "ltr",
454
+ },
455
+ containerWithInputs: {
456
+ direction: "ltr",
186
457
  },
187
458
  railContainer: {
188
459
  justifyContent: "center",
@@ -213,6 +484,34 @@ const getStyles = (theme: ThemeType) =>
213
484
  borderColor: theme.primary,
214
485
  borderWidth: 5,
215
486
  },
487
+ inputsContainer: {
488
+ flexDirection: "row",
489
+ justifyContent: "space-between",
490
+ gap: 16,
491
+ marginBottom: 12,
492
+ direction: I18nManager.isRTL ? "rtl" : "ltr",
493
+ },
494
+ inputWrapper: {
495
+ flex: 1,
496
+ },
497
+ inputLabel: {
498
+ color: theme.onSurface,
499
+ fontSize: 13,
500
+ fontFamily: "DinMedium",
501
+ marginBottom: 6,
502
+ },
503
+ input: {
504
+ backgroundColor: theme.background,
505
+ paddingHorizontal: 16,
506
+ paddingVertical: 10,
507
+ borderRadius: 8,
508
+ borderWidth: 1,
509
+ borderColor: theme.surfaceVariant,
510
+ color: theme.onSurface,
511
+ fontSize: 15,
512
+ fontFamily: "DinBold",
513
+ textAlign: "center",
514
+ },
216
515
  });
217
516
 
218
517
  export default RangeSlider;