@ecohouse/ui 0.1.18 → 0.1.19

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.
@@ -0,0 +1,755 @@
1
+ import {
2
+ forwardRef,
3
+ useCallback,
4
+ useEffect,
5
+ useMemo,
6
+ useRef,
7
+ useState,
8
+ type ComponentRef,
9
+ } from "react";
10
+ import {
11
+ PanResponder,
12
+ Platform,
13
+ Pressable,
14
+ StyleSheet,
15
+ Text,
16
+ TextInput,
17
+ View,
18
+ type AccessibilityActionEvent,
19
+ type GestureResponderEvent,
20
+ type LayoutChangeEvent,
21
+ type PointerEvent,
22
+ type TextStyle,
23
+ } from "react-native";
24
+ import { colors, fonts } from "../../theme";
25
+ import { mergeRefs } from "../../utils/mergeRefs";
26
+ import { useApplyWebClassName } from "../../utils/useApplyWebClassName";
27
+ import type { RangeProps, RangeValue } from "./Range.types";
28
+ import {
29
+ clampAndSnap,
30
+ clampRangeValue,
31
+ normalizeRangeValue,
32
+ parseRangeDraft,
33
+ snapRangeDelta,
34
+ } from "./Range.utils";
35
+
36
+ const DEFAULT_WIDTH = 347;
37
+ const TRACK_HEIGHT = 4;
38
+ const TRACK_HORIZONTAL_INSET = 20;
39
+ const THUMB_SIZE = 20;
40
+ const INPUT_WIDTH = 120;
41
+
42
+ type WebKeyboardEvent = {
43
+ key?: string;
44
+ nativeEvent?: { key?: string };
45
+ preventDefault: () => void;
46
+ stopPropagation: () => void;
47
+ };
48
+
49
+ function defaultParseValue(value: string) {
50
+ const normalized = value.replace(/[\s,]/g, "");
51
+ if (!normalized) return undefined;
52
+ const parsed = Number(normalized);
53
+ return Number.isFinite(parsed) ? parsed : undefined;
54
+ }
55
+
56
+ function valuesEqual(a: RangeValue, b: RangeValue) {
57
+ return a.from === b.from && a.to === b.to;
58
+ }
59
+
60
+ export const Range = forwardRef<ComponentRef<typeof View>, RangeProps>(function Range(
61
+ {
62
+ min = 0,
63
+ max = 100,
64
+ step = 1,
65
+ value,
66
+ defaultValue = {},
67
+ onValueChange,
68
+ fromPlaceholder = "Սկսած",
69
+ toPlaceholder = "Մինչև",
70
+ currency = "֏",
71
+ fromAccessibilityLabel = fromPlaceholder,
72
+ toAccessibilityLabel = toPlaceholder,
73
+ disabled = false,
74
+ formatValue = String,
75
+ parseValue = defaultParseValue,
76
+ style,
77
+ className,
78
+ onLayout,
79
+ ...rest
80
+ },
81
+ forwardedRef,
82
+ ) {
83
+ const rootRef = useRef<ComponentRef<typeof View>>(null);
84
+ const fromThumbRef = useRef<ComponentRef<typeof View>>(null);
85
+ const toThumbRef = useRef<ComponentRef<typeof View>>(null);
86
+ const setRootRef = useMemo(() => mergeRefs(rootRef, forwardedRef), [forwardedRef]);
87
+ const lowerBound = Math.min(min, max);
88
+ const upperBound = Math.max(min, max);
89
+ const safeStep = Number.isFinite(step) && step > 0 ? step : 1;
90
+ const isControlled = value !== undefined;
91
+ const [uncontrolledValue, setUncontrolledValue] = useState(() =>
92
+ normalizeRangeValue(defaultValue, lowerBound, upperBound),
93
+ );
94
+ const resolvedValue = normalizeRangeValue(
95
+ isControlled ? value : uncontrolledValue,
96
+ lowerBound,
97
+ upperBound,
98
+ );
99
+ const latestValueRef = useRef(resolvedValue);
100
+ const [trackWidth, setTrackWidth] = useState(DEFAULT_WIDTH);
101
+ const [fromDraft, setFromDraft] = useState(
102
+ resolvedValue.from === undefined ? "" : formatValue(resolvedValue.from),
103
+ );
104
+ const [toDraft, setToDraft] = useState(
105
+ resolvedValue.to === undefined ? "" : formatValue(resolvedValue.to),
106
+ );
107
+ const [editingField, setEditingField] = useState<"from" | "to" | null>(null);
108
+ const [focusedThumb, setFocusedThumb] = useState<"from" | "to" | null>(null);
109
+ const dragStartRef = useRef({ from: lowerBound, to: upperBound });
110
+ const nativeDragTargetRef = useRef<"from" | "to" | null>(null);
111
+ const pointerDragRef = useRef<{
112
+ target: "from" | "to";
113
+ pointerId: number;
114
+ startPageX: number;
115
+ startValue: number;
116
+ coincident: boolean;
117
+ } | null>(null);
118
+
119
+ useApplyWebClassName(rootRef, className?.trim() || undefined);
120
+
121
+ useEffect(() => {
122
+ if (editingField !== "from") {
123
+ setFromDraft(resolvedValue.from === undefined ? "" : formatValue(resolvedValue.from));
124
+ }
125
+ }, [editingField, formatValue, resolvedValue.from]);
126
+
127
+ useEffect(() => {
128
+ if (editingField !== "to") {
129
+ setToDraft(resolvedValue.to === undefined ? "" : formatValue(resolvedValue.to));
130
+ }
131
+ }, [editingField, formatValue, resolvedValue.to]);
132
+
133
+ useEffect(() => {
134
+ const nextValue = { from: resolvedValue.from, to: resolvedValue.to };
135
+ const changedOutsideInteraction = !valuesEqual(nextValue, latestValueRef.current);
136
+ latestValueRef.current = nextValue;
137
+
138
+ if (!changedOutsideInteraction) return;
139
+ if (editingField === "from") {
140
+ setFromDraft(nextValue.from === undefined ? "" : formatValue(nextValue.from));
141
+ }
142
+ if (editingField === "to") {
143
+ setToDraft(nextValue.to === undefined ? "" : formatValue(nextValue.to));
144
+ }
145
+ }, [editingField, formatValue, resolvedValue.from, resolvedValue.to]);
146
+
147
+ useEffect(() => {
148
+ if (isControlled) return;
149
+ setUncontrolledValue((current) => {
150
+ const normalized = normalizeRangeValue(current, lowerBound, upperBound);
151
+ return valuesEqual(current, normalized) ? current : normalized;
152
+ });
153
+ }, [isControlled, lowerBound, upperBound]);
154
+
155
+ const emitValue = useCallback(
156
+ (nextValue: RangeValue) => {
157
+ const normalized = normalizeRangeValue(nextValue, lowerBound, upperBound);
158
+ if (valuesEqual(normalized, latestValueRef.current)) return;
159
+ latestValueRef.current = normalized;
160
+ if (!isControlled) setUncontrolledValue(normalized);
161
+ onValueChange?.(normalized);
162
+ },
163
+ [isControlled, lowerBound, onValueChange, upperBound],
164
+ );
165
+
166
+ const updateFrom = useCallback(
167
+ (next: number | undefined) => {
168
+ const current = latestValueRef.current;
169
+ const maximum = current.to ?? upperBound;
170
+ const snapped =
171
+ next === undefined ? undefined : clampAndSnap(next, lowerBound, upperBound, safeStep);
172
+ emitValue({
173
+ ...current,
174
+ from: snapped === undefined ? undefined : clampRangeValue(snapped, lowerBound, maximum),
175
+ });
176
+ },
177
+ [emitValue, lowerBound, safeStep, upperBound],
178
+ );
179
+
180
+ const updateTo = useCallback(
181
+ (next: number | undefined) => {
182
+ const current = latestValueRef.current;
183
+ const minimum = current.from ?? lowerBound;
184
+ const snapped =
185
+ next === undefined ? undefined : clampAndSnap(next, lowerBound, upperBound, safeStep);
186
+ emitValue({
187
+ ...current,
188
+ to: snapped === undefined ? undefined : clampRangeValue(snapped, minimum, upperBound),
189
+ });
190
+ },
191
+ [emitValue, lowerBound, safeStep, upperBound],
192
+ );
193
+
194
+ const updateFromInput = useCallback(
195
+ (next: number | undefined) => {
196
+ const current = latestValueRef.current;
197
+ const maximum = current.to ?? upperBound;
198
+ emitValue({
199
+ ...current,
200
+ from: next === undefined ? undefined : clampRangeValue(next, lowerBound, maximum),
201
+ });
202
+ },
203
+ [emitValue, lowerBound, upperBound],
204
+ );
205
+
206
+ const updateToInput = useCallback(
207
+ (next: number | undefined) => {
208
+ const current = latestValueRef.current;
209
+ const minimum = current.from ?? lowerBound;
210
+ emitValue({
211
+ ...current,
212
+ to: next === undefined ? undefined : clampRangeValue(next, minimum, upperBound),
213
+ });
214
+ },
215
+ [emitValue, lowerBound, upperBound],
216
+ );
217
+
218
+ const span = upperBound - lowerBound;
219
+ const thumbTravel = Math.max(0, trackWidth - TRACK_HORIZONTAL_INSET * 2 - THUMB_SIZE);
220
+ const effectiveFrom = resolvedValue.from ?? lowerBound;
221
+ const effectiveTo = resolvedValue.to ?? upperBound;
222
+ const valueToOffset = useCallback(
223
+ (next: number) => (span === 0 ? 0 : ((next - lowerBound) / span) * thumbTravel),
224
+ [lowerBound, span, thumbTravel],
225
+ );
226
+ const deltaToValue = useCallback(
227
+ (delta: number) => (thumbTravel === 0 || span === 0 ? 0 : (delta / thumbTravel) * span),
228
+ [span, thumbTravel],
229
+ );
230
+ const fromOffset = valueToOffset(effectiveFrom);
231
+ const toOffset = valueToOffset(effectiveTo);
232
+
233
+ const fromPanResponder = useMemo(
234
+ () =>
235
+ PanResponder.create({
236
+ onStartShouldSetPanResponder: () => !disabled,
237
+ onMoveShouldSetPanResponder: () => !disabled,
238
+ onPanResponderGrant: () => {
239
+ dragStartRef.current = { from: effectiveFrom, to: effectiveTo };
240
+ nativeDragTargetRef.current = effectiveFrom === effectiveTo ? null : "from";
241
+ },
242
+ onPanResponderMove: (_, gesture) => {
243
+ if (!nativeDragTargetRef.current && gesture.dx !== 0) {
244
+ nativeDragTargetRef.current = gesture.dx < 0 ? "from" : "to";
245
+ }
246
+ const delta = deltaToValue(gesture.dx);
247
+ if (nativeDragTargetRef.current === "to") {
248
+ updateToInput(snapRangeDelta(dragStartRef.current.to, delta, safeStep));
249
+ } else if (nativeDragTargetRef.current === "from") {
250
+ updateFromInput(snapRangeDelta(dragStartRef.current.from, delta, safeStep));
251
+ }
252
+ },
253
+ onPanResponderRelease: () => {
254
+ nativeDragTargetRef.current = null;
255
+ },
256
+ onPanResponderTerminate: () => {
257
+ nativeDragTargetRef.current = null;
258
+ },
259
+ }),
260
+ [deltaToValue, disabled, effectiveFrom, effectiveTo, safeStep, updateFromInput, updateToInput],
261
+ );
262
+
263
+ const toPanResponder = useMemo(
264
+ () =>
265
+ PanResponder.create({
266
+ onStartShouldSetPanResponder: () => !disabled,
267
+ onMoveShouldSetPanResponder: () => !disabled,
268
+ onPanResponderGrant: () => {
269
+ dragStartRef.current = { from: effectiveFrom, to: effectiveTo };
270
+ nativeDragTargetRef.current = effectiveFrom === effectiveTo ? null : "to";
271
+ },
272
+ onPanResponderMove: (_, gesture) => {
273
+ if (!nativeDragTargetRef.current && gesture.dx !== 0) {
274
+ nativeDragTargetRef.current = gesture.dx < 0 ? "from" : "to";
275
+ }
276
+ const delta = deltaToValue(gesture.dx);
277
+ if (nativeDragTargetRef.current === "from") {
278
+ updateFromInput(snapRangeDelta(dragStartRef.current.from, delta, safeStep));
279
+ } else if (nativeDragTargetRef.current === "to") {
280
+ updateToInput(snapRangeDelta(dragStartRef.current.to, delta, safeStep));
281
+ }
282
+ },
283
+ onPanResponderRelease: () => {
284
+ nativeDragTargetRef.current = null;
285
+ },
286
+ onPanResponderTerminate: () => {
287
+ nativeDragTargetRef.current = null;
288
+ },
289
+ }),
290
+ [deltaToValue, disabled, effectiveFrom, effectiveTo, safeStep, updateFromInput, updateToInput],
291
+ );
292
+
293
+ const beginPointerDrag = (target: "from" | "to", currentValue: number, event: PointerEvent) => {
294
+ if (disabled) return;
295
+ event.stopPropagation();
296
+ const pointerId = event.nativeEvent.pointerId;
297
+ const node = event.currentTarget as unknown as {
298
+ focus?: () => void;
299
+ setPointerCapture?: (id: number) => void;
300
+ };
301
+ node.focus?.();
302
+ node.setPointerCapture?.(pointerId);
303
+ pointerDragRef.current = {
304
+ target,
305
+ pointerId,
306
+ startPageX: event.nativeEvent.pageX,
307
+ startValue: currentValue,
308
+ coincident: effectiveFrom === effectiveTo,
309
+ };
310
+ };
311
+
312
+ const movePointerDrag = (event: PointerEvent) => {
313
+ const drag = pointerDragRef.current;
314
+ if (!drag || drag.pointerId !== event.nativeEvent.pointerId) return;
315
+ const deltaX = event.nativeEvent.pageX - drag.startPageX;
316
+ const nextValue = snapRangeDelta(drag.startValue, deltaToValue(deltaX), safeStep);
317
+ if (drag.coincident && deltaX !== 0) {
318
+ drag.target = deltaX < 0 ? "from" : "to";
319
+ drag.coincident = false;
320
+ }
321
+ const target = drag.target;
322
+ if (target === "from") updateFromInput(nextValue);
323
+ else updateToInput(nextValue);
324
+ };
325
+
326
+ const endPointerDrag = (event: PointerEvent) => {
327
+ const drag = pointerDragRef.current;
328
+ if (!drag || drag.pointerId !== event.nativeEvent.pointerId) return;
329
+ const node = event.currentTarget as unknown as {
330
+ hasPointerCapture?: (id: number) => boolean;
331
+ releasePointerCapture?: (id: number) => void;
332
+ };
333
+ if (node.hasPointerCapture?.(drag.pointerId)) node.releasePointerCapture?.(drag.pointerId);
334
+ pointerDragRef.current = null;
335
+ };
336
+
337
+ const handleAccessibilityAction = (
338
+ event: AccessibilityActionEvent,
339
+ current: number,
340
+ update: (value: number) => void,
341
+ ) => {
342
+ if (disabled) return;
343
+ if (event.nativeEvent.actionName === "increment") update(current + safeStep);
344
+ if (event.nativeEvent.actionName === "decrement") update(current - safeStep);
345
+ };
346
+
347
+ const handleWebKeyDown = (
348
+ event: WebKeyboardEvent,
349
+ current: number,
350
+ minimum: number,
351
+ maximum: number,
352
+ update: (value: number) => void,
353
+ ) => {
354
+ if (disabled) return;
355
+
356
+ const key = event.nativeEvent?.key ?? event.key;
357
+ let nextValue: number | undefined;
358
+
359
+ if (key === "ArrowRight" || key === "ArrowUp") nextValue = current + safeStep;
360
+ if (key === "ArrowLeft" || key === "ArrowDown") nextValue = current - safeStep;
361
+ if (key === "Home") nextValue = minimum;
362
+ if (key === "End") nextValue = maximum;
363
+
364
+ if (nextValue === undefined) return;
365
+ event.preventDefault();
366
+ event.stopPropagation();
367
+ update(nextValue);
368
+ };
369
+
370
+ const commitDraft = (draft: string, update: (value: number | undefined) => void) => {
371
+ const result = parseRangeDraft(draft, parseValue);
372
+ if (result.kind === "empty") update(undefined);
373
+ if (result.kind === "value") update(result.value);
374
+ };
375
+
376
+ const changeFromDraft = (draft: string) => {
377
+ setFromDraft(draft);
378
+ const result = parseRangeDraft(draft, parseValue);
379
+ if (result.kind === "empty") updateFromInput(undefined);
380
+ if (result.kind === "value") updateFromInput(result.value);
381
+ };
382
+
383
+ const changeToDraft = (draft: string) => {
384
+ setToDraft(draft);
385
+ const result = parseRangeDraft(draft, parseValue);
386
+ if (result.kind === "empty") updateToInput(undefined);
387
+ if (result.kind === "value") updateToInput(result.value);
388
+ };
389
+
390
+ const focusWebThumb = (target: "from" | "to") => {
391
+ if (Platform.OS !== "web") return;
392
+ const node = (target === "from" ? fromThumbRef.current : toThumbRef.current) as unknown as {
393
+ focus?: () => void;
394
+ } | null;
395
+ node?.focus?.();
396
+ };
397
+
398
+ const updateNearestFromTrack = (locationX: number) => {
399
+ if (disabled || thumbTravel === 0 || span === 0) return;
400
+
401
+ const offset = Math.min(
402
+ thumbTravel,
403
+ Math.max(0, locationX - TRACK_HORIZONTAL_INSET - THUMB_SIZE / 2),
404
+ );
405
+ const nextValue = clampAndSnap(
406
+ lowerBound + (offset / thumbTravel) * span,
407
+ lowerBound,
408
+ upperBound,
409
+ safeStep,
410
+ );
411
+
412
+ const fromDistance = Math.abs(nextValue - effectiveFrom);
413
+ const toDistance = Math.abs(nextValue - effectiveTo);
414
+
415
+ if (effectiveFrom === effectiveTo && fromDistance === toDistance) {
416
+ if (nextValue < effectiveFrom) {
417
+ focusWebThumb("from");
418
+ updateFrom(nextValue);
419
+ }
420
+ if (nextValue > effectiveTo) {
421
+ focusWebThumb("to");
422
+ updateTo(nextValue);
423
+ }
424
+ } else if (fromDistance <= toDistance) {
425
+ focusWebThumb("from");
426
+ updateFrom(nextValue);
427
+ } else {
428
+ focusWebThumb("to");
429
+ updateTo(nextValue);
430
+ }
431
+ };
432
+
433
+ const handleTrackPress = (event: GestureResponderEvent) => {
434
+ updateNearestFromTrack(event.nativeEvent.locationX);
435
+ };
436
+
437
+ const handleTrackLayout = (event: LayoutChangeEvent) => {
438
+ setTrackWidth(event.nativeEvent.layout.width);
439
+ };
440
+
441
+ const fromValueText = `${formatValue(effectiveFrom)}${currency ? ` ${currency}` : ""}`;
442
+ const toValueText = `${formatValue(effectiveTo)}${currency ? ` ${currency}` : ""}`;
443
+
444
+ return (
445
+ <View
446
+ {...rest}
447
+ ref={setRootRef}
448
+ onLayout={onLayout}
449
+ style={[styles.root, disabled && styles.disabled, style]}
450
+ >
451
+ {/* The 20px plane keeps native hit-testing on the thumbs while the rail stays at y=8. */}
452
+ <Pressable
453
+ accessible={false}
454
+ disabled={disabled}
455
+ focusable={false}
456
+ onLayout={handleTrackLayout}
457
+ onPointerDown={
458
+ Platform.OS === "web"
459
+ ? (event) => {
460
+ event.preventDefault();
461
+ updateNearestFromTrack(event.nativeEvent.offsetX);
462
+ }
463
+ : undefined
464
+ }
465
+ onPress={Platform.OS === "web" ? undefined : handleTrackPress}
466
+ style={styles.sliderPlane}
467
+ tabIndex={-1}
468
+ >
469
+ <View pointerEvents="none" style={styles.track} />
470
+ <View
471
+ pointerEvents="none"
472
+ style={[
473
+ styles.activeTrack,
474
+ {
475
+ left: TRACK_HORIZONTAL_INSET + fromOffset,
476
+ width: toOffset - fromOffset + THUMB_SIZE,
477
+ },
478
+ ]}
479
+ />
480
+ {/* Native Pressable owns responder handlers, so the draggable hosts intentionally stay Views. */}
481
+ <View
482
+ ref={fromThumbRef}
483
+ {...(Platform.OS === "web"
484
+ ? {
485
+ onPointerCancel: endPointerDrag,
486
+ onPointerDown: (event: PointerEvent) =>
487
+ beginPointerDrag("from", effectiveFrom, event),
488
+ onLostPointerCapture: endPointerDrag,
489
+ onPointerMove: movePointerDrag,
490
+ onPointerUp: endPointerDrag,
491
+ onBlur: () => setFocusedThumb(null),
492
+ onFocus: () => setFocusedThumb("from"),
493
+ onKeyDown: (event: WebKeyboardEvent) =>
494
+ handleWebKeyDown(event, effectiveFrom, lowerBound, effectiveTo, updateFromInput),
495
+ }
496
+ : fromPanResponder.panHandlers)}
497
+ aria-disabled={disabled}
498
+ aria-valuemax={effectiveTo}
499
+ aria-valuemin={lowerBound}
500
+ aria-valuenow={effectiveFrom}
501
+ aria-valuetext={fromValueText}
502
+ accessible
503
+ accessibilityActions={[{ name: "increment" }, { name: "decrement" }]}
504
+ accessibilityLabel={fromAccessibilityLabel}
505
+ accessibilityRole="adjustable"
506
+ accessibilityState={{ disabled }}
507
+ accessibilityValue={{
508
+ min: lowerBound,
509
+ max: effectiveTo,
510
+ now: effectiveFrom,
511
+ text: fromValueText,
512
+ }}
513
+ focusable={!disabled}
514
+ onAccessibilityAction={(event) =>
515
+ handleAccessibilityAction(event, effectiveFrom, updateFromInput)
516
+ }
517
+ pointerEvents={disabled ? "none" : "auto"}
518
+ style={[
519
+ styles.thumb,
520
+ Platform.OS === "web" && styles.thumbWeb,
521
+ focusedThumb === "from" && Platform.OS === "web" && styles.thumbFocusedWeb,
522
+ { left: TRACK_HORIZONTAL_INSET + fromOffset },
523
+ ]}
524
+ tabIndex={disabled ? -1 : 0}
525
+ />
526
+ <View
527
+ ref={toThumbRef}
528
+ {...(Platform.OS === "web"
529
+ ? {
530
+ onPointerCancel: endPointerDrag,
531
+ onPointerDown: (event: PointerEvent) => beginPointerDrag("to", effectiveTo, event),
532
+ onLostPointerCapture: endPointerDrag,
533
+ onPointerMove: movePointerDrag,
534
+ onPointerUp: endPointerDrag,
535
+ onBlur: () => setFocusedThumb(null),
536
+ onFocus: () => setFocusedThumb("to"),
537
+ onKeyDown: (event: WebKeyboardEvent) =>
538
+ handleWebKeyDown(event, effectiveTo, effectiveFrom, upperBound, updateToInput),
539
+ }
540
+ : toPanResponder.panHandlers)}
541
+ aria-disabled={disabled}
542
+ aria-valuemax={upperBound}
543
+ aria-valuemin={effectiveFrom}
544
+ aria-valuenow={effectiveTo}
545
+ aria-valuetext={toValueText}
546
+ accessible
547
+ accessibilityActions={[{ name: "increment" }, { name: "decrement" }]}
548
+ accessibilityLabel={toAccessibilityLabel}
549
+ accessibilityRole="adjustable"
550
+ accessibilityState={{ disabled }}
551
+ accessibilityValue={{
552
+ min: effectiveFrom,
553
+ max: upperBound,
554
+ now: effectiveTo,
555
+ text: toValueText,
556
+ }}
557
+ focusable={!disabled}
558
+ onAccessibilityAction={(event) =>
559
+ handleAccessibilityAction(event, effectiveTo, updateToInput)
560
+ }
561
+ pointerEvents={disabled ? "none" : "auto"}
562
+ style={[
563
+ styles.thumb,
564
+ Platform.OS === "web" && styles.thumbWeb,
565
+ focusedThumb === "to" && Platform.OS === "web" && styles.thumbFocusedWeb,
566
+ { left: TRACK_HORIZONTAL_INSET + toOffset },
567
+ ]}
568
+ tabIndex={disabled ? -1 : 0}
569
+ />
570
+ </Pressable>
571
+
572
+ <View style={styles.inputs}>
573
+ <RangeInput
574
+ accessibilityLabel={fromAccessibilityLabel}
575
+ currency={currency}
576
+ disabled={disabled}
577
+ focused={editingField === "from"}
578
+ onChangeText={changeFromDraft}
579
+ onCommit={() => {
580
+ setEditingField(null);
581
+ commitDraft(fromDraft, updateFromInput);
582
+ }}
583
+ onFocus={() => setEditingField("from")}
584
+ placeholder={fromPlaceholder}
585
+ value={fromDraft}
586
+ />
587
+ <RangeInput
588
+ accessibilityLabel={toAccessibilityLabel}
589
+ currency={currency}
590
+ disabled={disabled}
591
+ focused={editingField === "to"}
592
+ onChangeText={changeToDraft}
593
+ onCommit={() => {
594
+ setEditingField(null);
595
+ commitDraft(toDraft, updateToInput);
596
+ }}
597
+ onFocus={() => setEditingField("to")}
598
+ placeholder={toPlaceholder}
599
+ value={toDraft}
600
+ />
601
+ </View>
602
+ </View>
603
+ );
604
+ });
605
+
606
+ function RangeInput({
607
+ accessibilityLabel,
608
+ currency,
609
+ disabled,
610
+ focused,
611
+ onChangeText,
612
+ onCommit,
613
+ onFocus,
614
+ placeholder,
615
+ value,
616
+ }: {
617
+ accessibilityLabel: string;
618
+ currency: string;
619
+ disabled: boolean;
620
+ focused: boolean;
621
+ onChangeText: (value: string) => void;
622
+ onCommit: () => void;
623
+ onFocus: () => void;
624
+ placeholder: string;
625
+ value: string;
626
+ }) {
627
+ return (
628
+ <View style={[styles.inputField, focused && styles.inputFieldFocused]}>
629
+ <TextInput
630
+ aria-disabled={disabled}
631
+ accessibilityLabel={accessibilityLabel}
632
+ accessibilityState={{ disabled }}
633
+ editable={!disabled}
634
+ inputMode="decimal"
635
+ onBlur={onCommit}
636
+ onChangeText={onChangeText}
637
+ onFocus={onFocus}
638
+ onSubmitEditing={onCommit}
639
+ placeholder={placeholder}
640
+ placeholderTextColor={colors.grey150}
641
+ returnKeyType="done"
642
+ style={[styles.input, Platform.OS === "web" && styles.inputWeb]}
643
+ tabIndex={disabled ? -1 : undefined}
644
+ value={value}
645
+ />
646
+ <View style={styles.currencySlot}>
647
+ <Text style={styles.currency}>{currency}</Text>
648
+ </View>
649
+ </View>
650
+ );
651
+ }
652
+
653
+ const styles = StyleSheet.create({
654
+ root: {
655
+ width: DEFAULT_WIDTH,
656
+ gap: 15,
657
+ alignSelf: "flex-start",
658
+ },
659
+ disabled: {
660
+ opacity: 0.6,
661
+ },
662
+ sliderPlane: {
663
+ width: "100%",
664
+ height: THUMB_SIZE,
665
+ position: "relative",
666
+ },
667
+ track: {
668
+ position: "absolute",
669
+ top: 8,
670
+ left: 0,
671
+ width: "100%",
672
+ height: TRACK_HEIGHT,
673
+ borderRadius: 16,
674
+ backgroundColor: colors.grey600,
675
+ },
676
+ activeTrack: {
677
+ position: "absolute",
678
+ top: 8,
679
+ height: TRACK_HEIGHT,
680
+ borderRadius: 16,
681
+ backgroundColor: colors.primary,
682
+ },
683
+ thumb: {
684
+ position: "absolute",
685
+ top: 0,
686
+ width: THUMB_SIZE,
687
+ height: THUMB_SIZE,
688
+ borderRadius: 32,
689
+ borderWidth: 2,
690
+ borderColor: colors.primary,
691
+ backgroundColor: colors.white,
692
+ },
693
+ thumbWeb: {
694
+ cursor: "grab",
695
+ touchAction: "none",
696
+ } as unknown as TextStyle,
697
+ thumbFocusedWeb: {
698
+ outlineColor: colors.white,
699
+ outlineOffset: 2,
700
+ outlineStyle: "solid",
701
+ outlineWidth: 2,
702
+ } as TextStyle,
703
+ inputs: {
704
+ width: "100%",
705
+ height: 44,
706
+ flexDirection: "row",
707
+ alignItems: "center",
708
+ justifyContent: "space-between",
709
+ },
710
+ inputField: {
711
+ width: INPUT_WIDTH,
712
+ height: 44,
713
+ paddingVertical: 12,
714
+ paddingHorizontal: 16,
715
+ flexDirection: "row",
716
+ alignItems: "center",
717
+ gap: 10,
718
+ borderWidth: 1,
719
+ borderColor: colors.grey700,
720
+ borderRadius: 60,
721
+ backgroundColor: colors.grey700,
722
+ },
723
+ inputFieldFocused: {
724
+ borderColor: colors.primary,
725
+ },
726
+ input: {
727
+ minWidth: 0,
728
+ flex: 1,
729
+ margin: 0,
730
+ padding: 0,
731
+ fontFamily: fonts.sans,
732
+ fontSize: 12,
733
+ fontWeight: "400",
734
+ lineHeight: 16,
735
+ letterSpacing: 0.36,
736
+ color: colors.white,
737
+ },
738
+ inputWeb: {
739
+ outlineStyle: "none",
740
+ } as TextStyle,
741
+ currencySlot: {
742
+ width: 20,
743
+ height: 20,
744
+ alignItems: "center",
745
+ justifyContent: "center",
746
+ flexShrink: 0,
747
+ },
748
+ currency: {
749
+ fontFamily: fonts.sans,
750
+ fontSize: 16,
751
+ fontWeight: "600",
752
+ lineHeight: 22,
753
+ color: colors.white,
754
+ },
755
+ });