@atlure/ui 0.4.0 → 0.6.0
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.
- package/README.md +39 -6
- package/package.json +5 -6
- package/src/components/alert-dialog/alert-dialog.tsx +59 -0
- package/src/components/alert-dialog/utils.ts +5 -0
- package/src/components/avatar/avatar-group.tsx +52 -0
- package/src/components/avatar/avatar.tsx +54 -11
- package/src/components/badge/urgency-badge.tsx +21 -0
- package/src/components/calendar/calendar.tsx +189 -0
- package/src/components/checkbox/checkbox.tsx +26 -9
- package/src/components/chip/chip.tsx +66 -0
- package/src/components/date-range-picker/date-range-picker.tsx +104 -0
- package/src/components/dialog/dialog.tsx +137 -0
- package/src/components/format/date-label.tsx +41 -0
- package/src/components/format/distance-label.tsx +13 -0
- package/src/components/format/duration-label.tsx +13 -0
- package/src/components/format/money-label.tsx +20 -0
- package/src/components/picker/picker.tsx +104 -0
- package/src/components/progress/progress.tsx +95 -0
- package/src/components/progress/utils.ts +10 -0
- package/src/components/radio-group/radio-group-context.tsx +27 -0
- package/src/components/radio-group/radio-group.tsx +150 -0
- package/src/components/screen-header/screen-header.tsx +62 -0
- package/src/components/segmented-control/segmented-control.tsx +67 -0
- package/src/components/select/select.tsx +134 -0
- package/src/components/settings-row/settings-row.tsx +52 -0
- package/src/components/sheet/sheet.tsx +139 -0
- package/src/components/sheet/utils.ts +55 -0
- package/src/components/slider/format-label.ts +3 -0
- package/src/components/slider/hooks/use-slider-drag.ts +47 -0
- package/src/components/slider/range-slider.tsx +203 -0
- package/src/components/slider/slider.tsx +148 -0
- package/src/components/slider/utils.ts +57 -0
- package/src/components/star-rating/star-rating.tsx +98 -0
- package/src/components/switch/switch.tsx +34 -6
- package/src/components/tabs/tabs-context.tsx +32 -0
- package/src/components/tabs/tabs.tsx +239 -0
- package/src/components/time-picker/time-picker.tsx +131 -0
- package/src/components/toast/toast-context.tsx +26 -0
- package/src/components/toast/toast-provider.tsx +70 -0
- package/src/components/toast/toast.tsx +41 -0
- package/src/components/toast/utils.ts +2 -0
- package/src/index.ts +54 -0
- package/src/lib/calendar.ts +204 -0
- package/src/lib/format/date.ts +46 -0
- package/src/lib/format/distance.ts +36 -0
- package/src/lib/format/duration.ts +28 -0
- package/src/lib/format/money.ts +20 -0
- package/src/lib/locale.tsx +36 -0
- package/src/lib/portal.tsx +54 -0
- package/src/lib/touch-target.ts +5 -1
- package/src/lib/use-sheet.ts +18 -0
- package/src/lib/use-toast.ts +17 -0
- package/src/variants/avatar-variants.ts +68 -15
- package/src/variants/badge-variants.ts +8 -0
- package/src/variants/calendar-variants.ts +26 -0
- package/src/variants/checkbox-variants.ts +5 -14
- package/src/variants/chip-variants.ts +38 -0
- package/src/variants/dialog-variants.ts +11 -0
- package/src/variants/index.ts +9 -0
- package/src/variants/overlay-variants.ts +1 -0
- package/src/variants/progress-variants.ts +18 -0
- package/src/variants/radio-group-variants.ts +48 -0
- package/src/variants/screen-header-variants.ts +33 -0
- package/src/variants/segmented-control-variants.ts +41 -0
- package/src/variants/select-variants.ts +48 -0
- package/src/variants/settings-row-variants.ts +18 -0
- package/src/variants/sheet-variants.ts +10 -0
- package/src/variants/slider-variants.ts +8 -0
- package/src/variants/star-rating-variants.ts +28 -0
- package/src/variants/switch-variants.ts +26 -4
- package/src/variants/tabs-variants.ts +46 -0
- package/src/variants/toast-variants.ts +31 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { useCallback, useRef } from "react";
|
|
2
|
+
|
|
3
|
+
import { type SliderScale, valueFromPosition } from "../utils";
|
|
4
|
+
|
|
5
|
+
export interface SliderDragOptions {
|
|
6
|
+
scale: SliderScale;
|
|
7
|
+
trackLength: number;
|
|
8
|
+
value: number;
|
|
9
|
+
onValueChange?: (value: number) => void;
|
|
10
|
+
onValueCommit?: (value: number) => void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface SliderDrag {
|
|
14
|
+
moveTo: (position: number) => number;
|
|
15
|
+
commit: () => number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function useSliderDrag({
|
|
19
|
+
scale,
|
|
20
|
+
trackLength,
|
|
21
|
+
value,
|
|
22
|
+
onValueChange,
|
|
23
|
+
onValueCommit,
|
|
24
|
+
}: SliderDragOptions): SliderDrag {
|
|
25
|
+
const draggedValueRef = useRef<number | null>(null);
|
|
26
|
+
|
|
27
|
+
const moveTo = useCallback(
|
|
28
|
+
(position: number) => {
|
|
29
|
+
const nextValue = valueFromPosition(position, trackLength, scale);
|
|
30
|
+
draggedValueRef.current = nextValue;
|
|
31
|
+
onValueChange?.(nextValue);
|
|
32
|
+
|
|
33
|
+
return nextValue;
|
|
34
|
+
},
|
|
35
|
+
[trackLength, scale, onValueChange],
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
const commit = useCallback(() => {
|
|
39
|
+
const committedValue = draggedValueRef.current ?? value;
|
|
40
|
+
draggedValueRef.current = null;
|
|
41
|
+
onValueCommit?.(committedValue);
|
|
42
|
+
|
|
43
|
+
return committedValue;
|
|
44
|
+
}, [value, onValueCommit]);
|
|
45
|
+
|
|
46
|
+
return { moveTo, commit };
|
|
47
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { useCallback, useRef, useState } from "react";
|
|
2
|
+
import {
|
|
3
|
+
type AccessibilityActionEvent,
|
|
4
|
+
type LayoutChangeEvent,
|
|
5
|
+
Text,
|
|
6
|
+
View,
|
|
7
|
+
} from "react-native";
|
|
8
|
+
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
|
9
|
+
import Animated, {
|
|
10
|
+
runOnJS,
|
|
11
|
+
useAnimatedStyle,
|
|
12
|
+
useSharedValue,
|
|
13
|
+
} from "react-native-reanimated";
|
|
14
|
+
|
|
15
|
+
import { touchTargetHitSlopForSize } from "../../lib/touch-target";
|
|
16
|
+
import {
|
|
17
|
+
SLIDER_THUMB_SIZE,
|
|
18
|
+
sliderContainerClassName,
|
|
19
|
+
sliderFillClassName,
|
|
20
|
+
sliderLabelClassName,
|
|
21
|
+
sliderThumbClassName,
|
|
22
|
+
sliderTrackClassName,
|
|
23
|
+
} from "../../variants/slider-variants";
|
|
24
|
+
import type { RangeSliderFormatLabel } from "./format-label";
|
|
25
|
+
import {
|
|
26
|
+
clampRangeThumb,
|
|
27
|
+
positionFromValue,
|
|
28
|
+
type RangeThumb,
|
|
29
|
+
type RangeValue,
|
|
30
|
+
type SliderScale,
|
|
31
|
+
valueFromPosition,
|
|
32
|
+
} from "./utils";
|
|
33
|
+
|
|
34
|
+
export interface RangeSliderProps {
|
|
35
|
+
min: number;
|
|
36
|
+
max: number;
|
|
37
|
+
step?: number;
|
|
38
|
+
value: RangeValue;
|
|
39
|
+
onValueChange?: (value: RangeValue) => void;
|
|
40
|
+
onValueCommit?: (value: RangeValue) => void;
|
|
41
|
+
accessibilityLabel: string;
|
|
42
|
+
formatLabel?: RangeSliderFormatLabel;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function RangeSlider({
|
|
46
|
+
min,
|
|
47
|
+
max,
|
|
48
|
+
step = 1,
|
|
49
|
+
value,
|
|
50
|
+
onValueChange,
|
|
51
|
+
onValueCommit,
|
|
52
|
+
accessibilityLabel,
|
|
53
|
+
formatLabel,
|
|
54
|
+
}: RangeSliderProps) {
|
|
55
|
+
const scale: SliderScale = { min, max, step };
|
|
56
|
+
const [trackLength, setTrackLength] = useState(0);
|
|
57
|
+
const lowerPosition = useSharedValue(0);
|
|
58
|
+
const upperPosition = useSharedValue(0);
|
|
59
|
+
const draggedRangeRef = useRef<RangeValue | null>(null);
|
|
60
|
+
|
|
61
|
+
const emitChange = useCallback(
|
|
62
|
+
(next: RangeValue) => {
|
|
63
|
+
draggedRangeRef.current = next;
|
|
64
|
+
lowerPosition.value = positionFromValue(next[0], trackLength, scale);
|
|
65
|
+
upperPosition.value = positionFromValue(next[1], trackLength, scale);
|
|
66
|
+
onValueChange?.(next);
|
|
67
|
+
},
|
|
68
|
+
[lowerPosition, upperPosition, onValueChange, scale, trackLength],
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
const moveThumb = useCallback(
|
|
72
|
+
(thumb: RangeThumb, position: number) => {
|
|
73
|
+
const candidate = valueFromPosition(position, trackLength, scale);
|
|
74
|
+
const current = draggedRangeRef.current ?? value;
|
|
75
|
+
const next = clampRangeThumb(current, thumb, candidate, scale);
|
|
76
|
+
emitChange(next);
|
|
77
|
+
},
|
|
78
|
+
[emitChange, scale, trackLength, value],
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
const commit = useCallback(() => {
|
|
82
|
+
const committed = draggedRangeRef.current ?? value;
|
|
83
|
+
draggedRangeRef.current = null;
|
|
84
|
+
onValueCommit?.(committed);
|
|
85
|
+
}, [onValueCommit, value]);
|
|
86
|
+
|
|
87
|
+
const onTrackLayout = useCallback(
|
|
88
|
+
(event: LayoutChangeEvent) => {
|
|
89
|
+
const width = event.nativeEvent.layout.width;
|
|
90
|
+
setTrackLength(width);
|
|
91
|
+
lowerPosition.value = positionFromValue(value[0], width, scale);
|
|
92
|
+
upperPosition.value = positionFromValue(value[1], width, scale);
|
|
93
|
+
},
|
|
94
|
+
[lowerPosition, upperPosition, scale, value],
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
const buildGesture = (thumb: RangeThumb) =>
|
|
98
|
+
Gesture.Pan()
|
|
99
|
+
.minDistance(0)
|
|
100
|
+
.onStart((event) => {
|
|
101
|
+
runOnJS(moveThumb)(thumb, event.x);
|
|
102
|
+
})
|
|
103
|
+
.onUpdate((event) => {
|
|
104
|
+
runOnJS(moveThumb)(thumb, event.x);
|
|
105
|
+
})
|
|
106
|
+
.onEnd(() => {
|
|
107
|
+
runOnJS(commit)();
|
|
108
|
+
})
|
|
109
|
+
.onFinalize(() => {
|
|
110
|
+
runOnJS(commit)();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const lowerGesture = buildGesture("lower");
|
|
114
|
+
const upperGesture = buildGesture("upper");
|
|
115
|
+
|
|
116
|
+
const fillStyle = useAnimatedStyle(() => ({
|
|
117
|
+
left: lowerPosition.value,
|
|
118
|
+
width: Math.max(0, upperPosition.value - lowerPosition.value),
|
|
119
|
+
}));
|
|
120
|
+
const lowerThumbStyle = useAnimatedStyle(() => ({ left: lowerPosition.value }));
|
|
121
|
+
const upperThumbStyle = useAnimatedStyle(() => ({ left: upperPosition.value }));
|
|
122
|
+
|
|
123
|
+
const hitSlop = touchTargetHitSlopForSize(SLIDER_THUMB_SIZE);
|
|
124
|
+
const slopStyle = {
|
|
125
|
+
top: hitSlop,
|
|
126
|
+
bottom: hitSlop,
|
|
127
|
+
left: hitSlop,
|
|
128
|
+
right: hitSlop,
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const adjust = useCallback(
|
|
132
|
+
(thumb: RangeThumb, actionName: string) => {
|
|
133
|
+
const [lower, upper] = value;
|
|
134
|
+
const delta = actionName === "increment" ? step : -step;
|
|
135
|
+
const candidate = (thumb === "lower" ? lower : upper) + delta;
|
|
136
|
+
const next = clampRangeThumb(value, thumb, candidate, scale);
|
|
137
|
+
if (next[0] === lower && next[1] === upper) return;
|
|
138
|
+
onValueChange?.(next);
|
|
139
|
+
onValueCommit?.(next);
|
|
140
|
+
},
|
|
141
|
+
[onValueChange, onValueCommit, scale, step, value],
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
const onLowerAction = useCallback(
|
|
145
|
+
(event: AccessibilityActionEvent) => adjust("lower", event.nativeEvent.actionName),
|
|
146
|
+
[adjust],
|
|
147
|
+
);
|
|
148
|
+
const onUpperAction = useCallback(
|
|
149
|
+
(event: AccessibilityActionEvent) => adjust("upper", event.nativeEvent.actionName),
|
|
150
|
+
[adjust],
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
return (
|
|
154
|
+
<View className={sliderContainerClassName}>
|
|
155
|
+
<View
|
|
156
|
+
accessibilityLabel={accessibilityLabel}
|
|
157
|
+
accessibilityValue={{ min, max, now: value[1] }}
|
|
158
|
+
aria-valuemin={min}
|
|
159
|
+
aria-valuemax={max}
|
|
160
|
+
aria-valuenow={value[1]}
|
|
161
|
+
onLayout={onTrackLayout}
|
|
162
|
+
className={sliderTrackClassName}
|
|
163
|
+
>
|
|
164
|
+
<Animated.View className={sliderFillClassName} style={fillStyle} />
|
|
165
|
+
<GestureDetector gesture={lowerGesture}>
|
|
166
|
+
<Animated.View
|
|
167
|
+
accessibilityRole="adjustable"
|
|
168
|
+
accessibilityLabel={`${accessibilityLabel} lower bound`}
|
|
169
|
+
accessibilityValue={{ min, max: value[1], now: value[0] }}
|
|
170
|
+
aria-valuemin={min}
|
|
171
|
+
aria-valuemax={value[1]}
|
|
172
|
+
aria-valuenow={value[0]}
|
|
173
|
+
accessibilityActions={[{ name: "increment" }, { name: "decrement" }]}
|
|
174
|
+
onAccessibilityAction={onLowerAction}
|
|
175
|
+
className={sliderThumbClassName}
|
|
176
|
+
style={lowerThumbStyle}
|
|
177
|
+
hitSlop={slopStyle}
|
|
178
|
+
/>
|
|
179
|
+
</GestureDetector>
|
|
180
|
+
<GestureDetector gesture={upperGesture}>
|
|
181
|
+
<Animated.View
|
|
182
|
+
accessibilityRole="adjustable"
|
|
183
|
+
accessibilityLabel={`${accessibilityLabel} upper bound`}
|
|
184
|
+
accessibilityValue={{ min: value[0], max, now: value[1] }}
|
|
185
|
+
aria-valuemin={value[0]}
|
|
186
|
+
aria-valuemax={max}
|
|
187
|
+
aria-valuenow={value[1]}
|
|
188
|
+
accessibilityActions={[{ name: "increment" }, { name: "decrement" }]}
|
|
189
|
+
onAccessibilityAction={onUpperAction}
|
|
190
|
+
className={sliderThumbClassName}
|
|
191
|
+
style={upperThumbStyle}
|
|
192
|
+
hitSlop={slopStyle}
|
|
193
|
+
/>
|
|
194
|
+
</GestureDetector>
|
|
195
|
+
</View>
|
|
196
|
+
{formatLabel ? (
|
|
197
|
+
<Text className={sliderLabelClassName}>
|
|
198
|
+
{`${formatLabel(value[0], "lower")} - ${formatLabel(value[1], "upper")}`}
|
|
199
|
+
</Text>
|
|
200
|
+
) : null}
|
|
201
|
+
</View>
|
|
202
|
+
);
|
|
203
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { useCallback, useState, type ReactNode } from "react";
|
|
2
|
+
import {
|
|
3
|
+
type AccessibilityActionEvent,
|
|
4
|
+
type LayoutChangeEvent,
|
|
5
|
+
Text,
|
|
6
|
+
View,
|
|
7
|
+
} from "react-native";
|
|
8
|
+
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
|
9
|
+
import Animated, {
|
|
10
|
+
runOnJS,
|
|
11
|
+
useAnimatedStyle,
|
|
12
|
+
useSharedValue,
|
|
13
|
+
} from "react-native-reanimated";
|
|
14
|
+
|
|
15
|
+
import { touchTargetHitSlopForSize } from "../../lib/touch-target";
|
|
16
|
+
import {
|
|
17
|
+
SLIDER_THUMB_SIZE,
|
|
18
|
+
sliderContainerClassName,
|
|
19
|
+
sliderFillClassName,
|
|
20
|
+
sliderLabelClassName,
|
|
21
|
+
sliderThumbClassName,
|
|
22
|
+
sliderTrackClassName,
|
|
23
|
+
} from "../../variants/slider-variants";
|
|
24
|
+
import type { SliderFormatLabel } from "./format-label";
|
|
25
|
+
import { useSliderDrag } from "./hooks/use-slider-drag";
|
|
26
|
+
import {
|
|
27
|
+
positionFromValue,
|
|
28
|
+
snapToStep,
|
|
29
|
+
type SliderScale,
|
|
30
|
+
} from "./utils";
|
|
31
|
+
|
|
32
|
+
export interface SliderProps {
|
|
33
|
+
min: number;
|
|
34
|
+
max: number;
|
|
35
|
+
step?: number;
|
|
36
|
+
value: number;
|
|
37
|
+
onValueChange?: (value: number) => void;
|
|
38
|
+
onValueCommit?: (value: number) => void;
|
|
39
|
+
accessibilityLabel: string;
|
|
40
|
+
formatLabel?: SliderFormatLabel;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function Slider({
|
|
44
|
+
min,
|
|
45
|
+
max,
|
|
46
|
+
step = 1,
|
|
47
|
+
value,
|
|
48
|
+
onValueChange,
|
|
49
|
+
onValueCommit,
|
|
50
|
+
accessibilityLabel,
|
|
51
|
+
formatLabel,
|
|
52
|
+
}: SliderProps) {
|
|
53
|
+
const scale: SliderScale = { min, max, step };
|
|
54
|
+
const [trackLength, setTrackLength] = useState(0);
|
|
55
|
+
const clampedValue = snapToStep(value, scale);
|
|
56
|
+
const fillPosition = useSharedValue(0);
|
|
57
|
+
|
|
58
|
+
const drag = useSliderDrag({
|
|
59
|
+
scale,
|
|
60
|
+
trackLength,
|
|
61
|
+
value: clampedValue,
|
|
62
|
+
onValueChange: (next) => {
|
|
63
|
+
fillPosition.value = positionFromValue(next, trackLength, scale);
|
|
64
|
+
onValueChange?.(next);
|
|
65
|
+
},
|
|
66
|
+
onValueCommit,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const onTrackLayout = useCallback(
|
|
70
|
+
(event: LayoutChangeEvent) => {
|
|
71
|
+
const width = event.nativeEvent.layout.width;
|
|
72
|
+
setTrackLength(width);
|
|
73
|
+
fillPosition.value = positionFromValue(clampedValue, width, scale);
|
|
74
|
+
},
|
|
75
|
+
[clampedValue, fillPosition, scale],
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
const gesture = Gesture.Pan()
|
|
79
|
+
.minDistance(0)
|
|
80
|
+
.onStart((event) => {
|
|
81
|
+
runOnJS(drag.moveTo)(event.x);
|
|
82
|
+
})
|
|
83
|
+
.onUpdate((event) => {
|
|
84
|
+
runOnJS(drag.moveTo)(event.x);
|
|
85
|
+
})
|
|
86
|
+
.onEnd(() => {
|
|
87
|
+
runOnJS(drag.commit)();
|
|
88
|
+
})
|
|
89
|
+
.onFinalize(() => {
|
|
90
|
+
runOnJS(drag.commit)();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const fillStyle = useAnimatedStyle(() => ({ width: fillPosition.value }));
|
|
94
|
+
const thumbStyle = useAnimatedStyle(() => ({ left: fillPosition.value }));
|
|
95
|
+
|
|
96
|
+
const onAccessibilityAction = useCallback(
|
|
97
|
+
(event: AccessibilityActionEvent) => {
|
|
98
|
+
const next =
|
|
99
|
+
event.nativeEvent.actionName === "increment"
|
|
100
|
+
? snapToStep(clampedValue + step, scale)
|
|
101
|
+
: event.nativeEvent.actionName === "decrement"
|
|
102
|
+
? snapToStep(clampedValue - step, scale)
|
|
103
|
+
: clampedValue;
|
|
104
|
+
|
|
105
|
+
if (next === clampedValue) return;
|
|
106
|
+
|
|
107
|
+
onValueChange?.(next);
|
|
108
|
+
onValueCommit?.(next);
|
|
109
|
+
},
|
|
110
|
+
[clampedValue, onValueChange, onValueCommit, scale, step],
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
const hitSlop = touchTargetHitSlopForSize(SLIDER_THUMB_SIZE);
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
<View className={sliderContainerClassName}>
|
|
117
|
+
<GestureDetector gesture={gesture}>
|
|
118
|
+
<View
|
|
119
|
+
accessibilityRole="adjustable"
|
|
120
|
+
accessibilityLabel={accessibilityLabel}
|
|
121
|
+
accessibilityValue={{ min, max, now: clampedValue }}
|
|
122
|
+
aria-valuemin={min}
|
|
123
|
+
aria-valuemax={max}
|
|
124
|
+
aria-valuenow={clampedValue}
|
|
125
|
+
accessibilityActions={[{ name: "increment" }, { name: "decrement" }]}
|
|
126
|
+
onAccessibilityAction={onAccessibilityAction}
|
|
127
|
+
onLayout={onTrackLayout}
|
|
128
|
+
className={sliderTrackClassName}
|
|
129
|
+
>
|
|
130
|
+
<Animated.View className={sliderFillClassName} style={fillStyle} />
|
|
131
|
+
<Animated.View
|
|
132
|
+
className={sliderThumbClassName}
|
|
133
|
+
style={thumbStyle}
|
|
134
|
+
hitSlop={{
|
|
135
|
+
top: hitSlop,
|
|
136
|
+
bottom: hitSlop,
|
|
137
|
+
left: hitSlop,
|
|
138
|
+
right: hitSlop,
|
|
139
|
+
}}
|
|
140
|
+
/>
|
|
141
|
+
</View>
|
|
142
|
+
</GestureDetector>
|
|
143
|
+
{formatLabel ? (
|
|
144
|
+
<Text className={sliderLabelClassName}>{formatLabel(clampedValue) as ReactNode}</Text>
|
|
145
|
+
) : null}
|
|
146
|
+
</View>
|
|
147
|
+
);
|
|
148
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
export interface SliderScale {
|
|
2
|
+
min: number;
|
|
3
|
+
max: number;
|
|
4
|
+
step: number;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export type RangeValue = readonly [number, number];
|
|
8
|
+
export type RangeThumb = "lower" | "upper";
|
|
9
|
+
|
|
10
|
+
export function snapToStep(value: number, { min, max, step }: SliderScale): number {
|
|
11
|
+
if (Number.isNaN(value)) return min;
|
|
12
|
+
if (value <= min) return min;
|
|
13
|
+
if (value >= max) return max;
|
|
14
|
+
if (step <= 0) return value;
|
|
15
|
+
|
|
16
|
+
const snapped = min + Math.round((value - min) / step) * step;
|
|
17
|
+
|
|
18
|
+
return Math.min(Math.max(snapped, min), max);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function valueFromPosition(
|
|
22
|
+
position: number,
|
|
23
|
+
trackLength: number,
|
|
24
|
+
scale: SliderScale,
|
|
25
|
+
): number {
|
|
26
|
+
if (trackLength <= 0) return scale.min;
|
|
27
|
+
|
|
28
|
+
const ratio = Math.min(Math.max(position / trackLength, 0), 1);
|
|
29
|
+
|
|
30
|
+
return snapToStep(scale.min + ratio * (scale.max - scale.min), scale);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function positionFromValue(
|
|
34
|
+
value: number,
|
|
35
|
+
trackLength: number,
|
|
36
|
+
{ min, max }: SliderScale,
|
|
37
|
+
): number {
|
|
38
|
+
if (max === min) return 0;
|
|
39
|
+
|
|
40
|
+
const ratio = (Math.min(Math.max(value, min), max) - min) / (max - min);
|
|
41
|
+
|
|
42
|
+
return ratio * trackLength;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function clampRangeThumb(
|
|
46
|
+
range: RangeValue,
|
|
47
|
+
thumb: RangeThumb,
|
|
48
|
+
candidate: number,
|
|
49
|
+
scale: SliderScale,
|
|
50
|
+
): RangeValue {
|
|
51
|
+
const snapped = snapToStep(candidate, scale);
|
|
52
|
+
const [lower, upper] = range;
|
|
53
|
+
|
|
54
|
+
return thumb === "lower"
|
|
55
|
+
? [Math.min(snapped, upper), upper]
|
|
56
|
+
: [lower, Math.max(snapped, lower)];
|
|
57
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { Star, StarHalf } from "@atlure/icons";
|
|
2
|
+
import { Pressable, View, type ViewProps } from "react-native";
|
|
3
|
+
|
|
4
|
+
import { cn } from "../../lib/cn";
|
|
5
|
+
import { touchTargetHitSlopForSize } from "../../lib/touch-target";
|
|
6
|
+
import {
|
|
7
|
+
starRatingClassName,
|
|
8
|
+
starRatingEmptyClassName,
|
|
9
|
+
starRatingFilledClassName,
|
|
10
|
+
starRatingIconSize,
|
|
11
|
+
starRatingValueVariants,
|
|
12
|
+
type StarRatingSize,
|
|
13
|
+
} from "../../variants/star-rating-variants";
|
|
14
|
+
import { Text } from "../text/text";
|
|
15
|
+
|
|
16
|
+
const halfStep = 0.5;
|
|
17
|
+
|
|
18
|
+
export type StarFill = "full" | "half" | "empty";
|
|
19
|
+
|
|
20
|
+
export function starFillAt(starNumber: number, value: number): StarFill {
|
|
21
|
+
if (value >= starNumber) {
|
|
22
|
+
return "full";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return value >= starNumber - halfStep ? "half" : "empty";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface StarRatingProps extends Omit<ViewProps, "children"> {
|
|
29
|
+
value: number;
|
|
30
|
+
max?: number;
|
|
31
|
+
size?: StarRatingSize;
|
|
32
|
+
showValue?: boolean;
|
|
33
|
+
isInteractive?: boolean;
|
|
34
|
+
onChange?: (value: number) => void;
|
|
35
|
+
rateAccessibilityLabel?: (starNumber: number, max: number) => string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function StarRating({
|
|
39
|
+
value,
|
|
40
|
+
max = 5,
|
|
41
|
+
size = "md",
|
|
42
|
+
showValue = false,
|
|
43
|
+
isInteractive = false,
|
|
44
|
+
onChange,
|
|
45
|
+
rateAccessibilityLabel = (starNumber, total) => `Rate ${starNumber} out of ${total}`,
|
|
46
|
+
accessibilityLabel,
|
|
47
|
+
className,
|
|
48
|
+
...viewProps
|
|
49
|
+
}: StarRatingProps) {
|
|
50
|
+
const starNumbers = Array.from({ length: max }, (_, index) => index + 1);
|
|
51
|
+
const pixelSize = starRatingIconSize[size];
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<View
|
|
55
|
+
accessibilityLabel={accessibilityLabel ?? `${value} out of ${max}`}
|
|
56
|
+
className={cn(starRatingClassName, className)}
|
|
57
|
+
{...viewProps}
|
|
58
|
+
>
|
|
59
|
+
{starNumbers.map((starNumber) => {
|
|
60
|
+
const fill = starFillAt(starNumber, value);
|
|
61
|
+
const isPainted = fill !== "empty";
|
|
62
|
+
const StarGlyph = fill === "half" ? StarHalf : Star;
|
|
63
|
+
|
|
64
|
+
const glyph = (
|
|
65
|
+
<StarGlyph
|
|
66
|
+
className={isPainted ? starRatingFilledClassName : starRatingEmptyClassName}
|
|
67
|
+
fill={isPainted ? "currentColor" : "none"}
|
|
68
|
+
size={pixelSize}
|
|
69
|
+
/>
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
if (!isInteractive) {
|
|
73
|
+
return (
|
|
74
|
+
<View key={starNumber} testID={`star-${fill}`}>
|
|
75
|
+
{glyph}
|
|
76
|
+
</View>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<Pressable
|
|
82
|
+
key={starNumber}
|
|
83
|
+
accessibilityRole="button"
|
|
84
|
+
accessibilityLabel={rateAccessibilityLabel(starNumber, max)}
|
|
85
|
+
hitSlop={touchTargetHitSlopForSize(pixelSize)}
|
|
86
|
+
onPress={() => onChange?.(starNumber)}
|
|
87
|
+
testID={`star-${fill}`}
|
|
88
|
+
>
|
|
89
|
+
{glyph}
|
|
90
|
+
</Pressable>
|
|
91
|
+
);
|
|
92
|
+
})}
|
|
93
|
+
{showValue ? (
|
|
94
|
+
<Text className={starRatingValueVariants({ size })}>{value.toFixed(1)}</Text>
|
|
95
|
+
) : null}
|
|
96
|
+
</View>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
@@ -1,15 +1,25 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useEffect, useRef } from "react";
|
|
2
|
+
import { Animated, Pressable, type PressableProps, View } from "react-native";
|
|
2
3
|
|
|
3
4
|
import { cn } from "../../lib/cn";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
5
|
+
import { touchTargetHitSlopForSize } from "../../lib/touch-target";
|
|
6
|
+
import {
|
|
7
|
+
switchThumbTravel,
|
|
8
|
+
switchThumbVariants,
|
|
9
|
+
switchTrackHeight,
|
|
10
|
+
switchTrackVariants,
|
|
11
|
+
type SwitchSize,
|
|
12
|
+
} from "../../variants/switch-variants";
|
|
6
13
|
import { Label } from "../label/label";
|
|
7
14
|
|
|
15
|
+
const thumbTransitionDurationMs = 150;
|
|
16
|
+
|
|
8
17
|
export interface SwitchProps
|
|
9
18
|
extends Omit<PressableProps, "children" | "disabled" | "onPress" | "accessibilityState"> {
|
|
10
19
|
isChecked: boolean;
|
|
11
20
|
onValueChange: (isChecked: boolean) => void;
|
|
12
21
|
label?: string;
|
|
22
|
+
size?: SwitchSize;
|
|
13
23
|
isDisabled?: boolean;
|
|
14
24
|
}
|
|
15
25
|
|
|
@@ -17,11 +27,27 @@ export function Switch({
|
|
|
17
27
|
isChecked,
|
|
18
28
|
onValueChange,
|
|
19
29
|
label,
|
|
30
|
+
size = "md",
|
|
20
31
|
isDisabled = false,
|
|
21
32
|
accessibilityLabel,
|
|
22
33
|
className,
|
|
23
34
|
...pressableProps
|
|
24
35
|
}: SwitchProps) {
|
|
36
|
+
const travel = switchThumbTravel[size];
|
|
37
|
+
const thumbOffset = useRef(new Animated.Value(isChecked ? travel : 0)).current;
|
|
38
|
+
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
const animation = Animated.timing(thumbOffset, {
|
|
41
|
+
toValue: isChecked ? travel : 0,
|
|
42
|
+
duration: thumbTransitionDurationMs,
|
|
43
|
+
useNativeDriver: true,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
animation.start();
|
|
47
|
+
|
|
48
|
+
return () => animation.stop();
|
|
49
|
+
}, [isChecked, travel, thumbOffset]);
|
|
50
|
+
|
|
25
51
|
return (
|
|
26
52
|
<Pressable
|
|
27
53
|
accessibilityRole="switch"
|
|
@@ -30,13 +56,15 @@ export function Switch({
|
|
|
30
56
|
aria-checked={isChecked}
|
|
31
57
|
aria-disabled={isDisabled}
|
|
32
58
|
disabled={isDisabled}
|
|
33
|
-
hitSlop={
|
|
59
|
+
hitSlop={touchTargetHitSlopForSize(switchTrackHeight[size])}
|
|
34
60
|
onPress={() => onValueChange(!isChecked)}
|
|
35
61
|
className={cn("flex-row items-center gap-sm", className)}
|
|
36
62
|
{...pressableProps}
|
|
37
63
|
>
|
|
38
|
-
<View className={switchTrackVariants({ isChecked, isDisabled })}>
|
|
39
|
-
<View
|
|
64
|
+
<View className={switchTrackVariants({ size, isChecked, isDisabled })}>
|
|
65
|
+
<Animated.View style={{ transform: [{ translateX: thumbOffset }] }}>
|
|
66
|
+
<View className={switchThumbVariants({ size, isChecked })} />
|
|
67
|
+
</Animated.View>
|
|
40
68
|
</View>
|
|
41
69
|
{label ? <Label isDisabled={isDisabled}>{label}</Label> : null}
|
|
42
70
|
</Pressable>
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { createContext, useContext, type ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
export interface TabsTriggerLayout {
|
|
4
|
+
x: number;
|
|
5
|
+
width: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface TabsControl {
|
|
9
|
+
activeValue: string;
|
|
10
|
+
hasBeenActivated: (value: string) => boolean;
|
|
11
|
+
select: (value: string) => void;
|
|
12
|
+
triggerLayouts: ReadonlyMap<string, TabsTriggerLayout>;
|
|
13
|
+
reportTriggerLayout: (value: string, layout: TabsTriggerLayout) => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const TabsContext = createContext<TabsControl | undefined>(undefined);
|
|
17
|
+
|
|
18
|
+
export function TabsProvider({
|
|
19
|
+
control,
|
|
20
|
+
children,
|
|
21
|
+
}: {
|
|
22
|
+
control: TabsControl;
|
|
23
|
+
children: ReactNode;
|
|
24
|
+
}) {
|
|
25
|
+
return <TabsContext.Provider value={control}>{children}</TabsContext.Provider>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function useTabsControl(): TabsControl | undefined {
|
|
29
|
+
return useContext(TabsContext);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export { TabsContext };
|