Compare commits

..

2 Commits

Author SHA1 Message Date
Yaël Perret
b6d6c02d0a feat : Slider et RangeSlider 2026-09-10 11:55:46 +02:00
Yaël Perret
cf59438c25 feat : Switch 2026-09-10 11:40:54 +02:00
10 changed files with 1575 additions and 18 deletions

View File

@@ -20,6 +20,12 @@ export default function HomeTabScreen() {
onPress={() => router.push('/index-old')} onPress={() => router.push('/index-old')}
accessibilityLabel="Ouvrir la page d'exemple des champs" accessibilityLabel="Ouvrir la page d'exemple des champs"
/> />
<Button
label="Voir la démo du switch"
variant="secondary"
onPress={() => router.push('/switch-demo')}
accessibilityLabel="Ouvrir la page de démonstration du switch"
/>
</View> </View>
</View> </View>
); );
@@ -43,5 +49,6 @@ const styles = StyleSheet.create({
}, },
actions: { actions: {
marginTop: spacing[16], marginTop: spacing[16],
gap: spacing[8],
}, },
}); });

View File

@@ -4,6 +4,7 @@ import * as SplashScreen from 'expo-splash-screen';
import * as SystemUI from 'expo-system-ui'; import * as SystemUI from 'expo-system-ui';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { StatusBar } from 'react-native'; import { StatusBar } from 'react-native';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { SafeAreaProvider } from 'react-native-safe-area-context'; import { SafeAreaProvider } from 'react-native-safe-area-context';
import { queryClient } from '../hooks/queryClient'; import { queryClient } from '../hooks/queryClient';
@@ -36,23 +37,25 @@ export default function RootLayout() {
if (!ready) return null; if (!ready) return null;
return ( return (
<QueryClientProvider client={queryClient}> <GestureHandlerRootView style={{ flex: 1 }}>
<SafeAreaProvider> <QueryClientProvider client={queryClient}>
<StatusBar <SafeAreaProvider>
barStyle="light-content" <StatusBar
backgroundColor={colors.deepBackground} barStyle="light-content"
translucent={false} backgroundColor={colors.deepBackground}
/> translucent={false}
<Stack screenOptions={{ headerShown: false, contentStyle: { backgroundColor: colors.deepBackground } }}> />
<Stack.Screen name="(auth)" /> <Stack screenOptions={{ headerShown: false, contentStyle: { backgroundColor: colors.deepBackground } }}>
<Stack.Screen name="(tabs)" /> <Stack.Screen name="(auth)" />
<Stack.Screen name="(org)" /> <Stack.Screen name="(tabs)" />
<Stack.Screen name="event/[id]" /> <Stack.Screen name="(org)" />
<Stack.Screen name="create/event" options={{ presentation: 'modal' }} /> <Stack.Screen name="switch-demo" />
<Stack.Screen name="create/post" options={{ presentation: 'modal' }} /> <Stack.Screen name="event/[id]" />
</Stack> <Stack.Screen name="create/event" options={{ presentation: 'modal' }} />
<Stack.Screen name="create/post" options={{ presentation: 'modal' }} />
</SafeAreaProvider> </Stack>
</QueryClientProvider> </SafeAreaProvider>
</QueryClientProvider>
</GestureHandlerRootView>
); );
} }

246
src/app/switch-demo.tsx Normal file
View File

@@ -0,0 +1,246 @@
import { useState } from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { Button } from '@/components/Button';
import { FormField, Switch } from '@/components/form';
import { RangeSlider, Screen, Slider } from '@/components/ui';
import { colors } from '@/theme/colors';
import { spacing } from '@/theme/spacing';
import { typography } from '@/theme/typography';
export default function SwitchDemoScreen() {
const [pushEnabled, setPushEnabled] = useState(true);
const [weeklyDigestEnabled, setWeeklyDigestEnabled] = useState(false);
const [locationEnabled, setLocationEnabled] = useState(true);
const [autoPlayEnabled, setAutoPlayEnabled] = useState(false);
const [radiusKm, setRadiusKm] = useState(25);
const [resultCount, setResultCount] = useState(128);
const [priceRange, setPriceRange] = useState<[number, number]>([0, 65]);
const formatKm = (v: number) => `${v} km`;
const formatChf = (v: number) => `${v} CHF`;
const formatPriceRange = (minValue: number, maxValue: number) => {
const left = minValue === 0 ? 'Gratuit' : `${minValue} CHF`;
return `${left} - ${maxValue} CHF`;
};
return (
<Screen contentContainerStyle={styles.content}>
<View style={styles.header}>
<Text style={styles.kicker}>DESIGN SYSTEM · SWITCH & SLIDER</Text>
<Text style={styles.title}>Demos phase 4</Text>
</View>
<FormField
label="Notifications push"
helper="Switch custom basé sur Expo/React Native"
>
<View style={styles.switchRow}>
<View style={styles.switchLabelWrap}>
<Text style={styles.switchLabel}>Activer les alertes de nouveaux événements</Text>
<Text style={styles.switchHint}>
{pushEnabled ? 'Activé' : 'Désactivé'}
</Text>
</View>
<Switch
accessibilityLabel="Activer les notifications push"
value={pushEnabled}
onValueChange={setPushEnabled}
/>
</View>
</FormField>
<FormField
label="Digest hebdo"
helper="Exemple de taille large"
>
<View style={styles.switchRow}>
<View style={styles.switchLabelWrap}>
<Text style={styles.switchLabel}>Recevoir un recap chaque lundi</Text>
<Text style={styles.switchHint}>
{weeklyDigestEnabled ? 'Activé' : 'Désactivé'}
</Text>
</View>
<Switch
accessibilityLabel="Activer le digest hebdomadaire"
value={weeklyDigestEnabled}
onValueChange={setWeeklyDigestEnabled}
size="lg"
/>
</View>
</FormField>
<FormField label="Etat desactive" helper="Le switch est non interactif">
<View style={styles.switchRow}>
<View style={styles.switchLabelWrap}>
<Text style={styles.switchLabel}>Mode maintenance</Text>
<Text style={styles.switchHint}>Indisponible</Text>
</View>
<Switch
accessibilityLabel="Switch desactive"
value
disabled
/>
</View>
</FormField>
<FormField
label="Exemples rapides"
helper="Cas d'usage supplementaires"
>
<View style={styles.switchStack}>
<View style={styles.switchRowCompact}>
<Text style={styles.switchLabel}>Localisation precise</Text>
<Switch
accessibilityLabel="Activer la localisation precise"
value={locationEnabled}
onValueChange={setLocationEnabled}
/>
</View>
<View style={styles.switchRowCompact}>
<Text style={styles.switchLabel}>Lecture auto des extraits</Text>
<Switch
accessibilityLabel="Activer la lecture automatique"
value={autoPlayEnabled}
onValueChange={setAutoPlayEnabled}
size="lg"
/>
</View>
<View style={styles.inlineActions}>
<Button
label="Reset switches"
variant="secondary"
onPress={() => {
setPushEnabled(true);
setWeeklyDigestEnabled(false);
setLocationEnabled(true);
setAutoPlayEnabled(false);
}}
/>
</View>
</View>
</FormField>
<View style={styles.sectionDivider} />
<FormField
label="B3 · Rayon de recherche"
helper={`Resultats: ${resultCount}`}
>
<Slider
label="Distance"
value={radiusKm}
onChange={setRadiusKm}
onChangeEnd={(v) => {
const nextCount = Math.max(12, 220 - v * 4);
setResultCount(nextCount);
}}
min={1}
max={50}
step={1}
formatValue={formatKm}
showBounds
ticks={[1, 10, 25, 50]}
/>
</FormField>
<FormField label="B3 · Fourchette de prix" helper="0 a 100 CHF">
<RangeSlider
label="Prix"
value={priceRange}
onChange={setPriceRange}
min={0}
max={100}
step={1}
minGap={1}
formatValue={formatChf}
formatRange={formatPriceRange}
showBounds
ticks={[0, 25, 50, 75, 100]}
/>
</FormField>
<FormField label="Curseur desactive" helper="Exemple disabled">
<Slider
label="Quota"
value={40}
onChange={() => undefined}
min={0}
max={100}
step={1}
formatValue={(v) => `${v}%`}
disabled
showBounds
/>
</FormField>
</Screen>
);
}
const styles = StyleSheet.create({
content: {
gap: spacing[16],
paddingTop: spacing[24],
},
header: {
gap: spacing[8],
marginBottom: spacing[8],
},
kicker: {
...typography.sectionLabel,
color: colors.textSecondary,
},
title: {
...typography.body,
color: colors.textPrimary,
},
switchRow: {
minHeight: 56,
borderWidth: 1,
borderColor: colors.inputBorder,
borderRadius: 14,
backgroundColor: colors.inputBackground,
paddingHorizontal: 14,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: spacing[12],
},
switchLabelWrap: {
flex: 1,
gap: 2,
},
switchLabel: {
...typography.body,
color: colors.textPrimary,
},
switchHint: {
...typography.caption,
color: colors.textSecondary,
},
switchStack: {
gap: spacing[8],
},
switchRowCompact: {
minHeight: 56,
borderWidth: 1,
borderColor: colors.inputBorder,
borderRadius: 14,
backgroundColor: colors.inputBackground,
paddingHorizontal: 14,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: spacing[12],
},
inlineActions: {
marginTop: spacing[4],
},
sectionDivider: {
height: 1,
backgroundColor: colors.divider,
marginVertical: spacing[4],
},
});

View File

@@ -0,0 +1,151 @@
import { LinearGradient } from 'expo-linear-gradient';
import { useEffect, useRef } from 'react';
import {
Animated,
Easing,
Pressable,
StyleSheet,
View,
type StyleProp,
type ViewStyle,
} from 'react-native';
import { colors } from '@/theme/colors';
export type SwitchSize = 'md' | 'lg';
export type SwitchProps = {
value: boolean;
onValueChange?: (value: boolean) => void;
disabled?: boolean;
testID?: string;
accessibilityLabel?: string;
size?: SwitchSize;
containerStyle?: StyleProp<ViewStyle>;
style?: StyleProp<ViewStyle>;
};
const SIZE_BY_VARIANT: Record<
SwitchSize,
{ width: number; height: number; thumb: number; padding: number }
> = {
md: { width: 56, height: 34, thumb: 26, padding: 4 },
lg: { width: 64, height: 38, thumb: 30, padding: 4 },
};
function Switch({
size = 'md',
value,
disabled,
onValueChange,
testID,
accessibilityLabel,
containerStyle,
style,
}: SwitchProps) {
const metrics = SIZE_BY_VARIANT[size];
const travel = metrics.width - metrics.padding * 2 - metrics.thumb;
const progress = useRef(new Animated.Value(value ? 1 : 0)).current;
useEffect(() => {
Animated.timing(progress, {
toValue: value ? 1 : 0,
duration: 180,
easing: Easing.out(Easing.cubic),
useNativeDriver: true,
}).start();
}, [progress, value]);
const thumbTranslateX = progress.interpolate({
inputRange: [0, 1],
outputRange: [0, travel],
});
return (
<Pressable
testID={testID}
accessibilityRole="switch"
accessibilityState={{ checked: value, disabled }}
accessibilityLabel={accessibilityLabel}
disabled={disabled}
onPress={() => onValueChange?.(!value)}
style={[
styles.container,
{
width: metrics.width,
height: metrics.height,
borderRadius: metrics.height / 2,
opacity: disabled ? 0.5 : 1,
},
containerStyle,
]}
>
{value ? (
<LinearGradient
colors={[colors.secondary, colors.primary]}
start={{ x: 0, y: 0.5 }}
end={{ x: 1, y: 0.5 }}
style={[
styles.trackFill,
{ borderRadius: metrics.height / 2, borderColor: 'rgba(255,255,255,0.18)' },
]}
pointerEvents="none"
/>
) : (
<View
style={[
styles.trackFill,
styles.trackOff,
{ borderRadius: metrics.height / 2 },
]}
pointerEvents="none"
/>
)}
<Animated.View
style={[
styles.thumb,
{
width: metrics.thumb,
height: metrics.thumb,
borderRadius: metrics.thumb / 2,
top: metrics.padding,
left: metrics.padding,
transform: [{ translateX: thumbTranslateX }],
backgroundColor: value ? '#FFFFFF' : colors.textStrong,
},
style,
]}
/>
</Pressable>
);
}
const styles = StyleSheet.create({
container: {
position: 'relative',
overflow: 'hidden',
},
trackFill: {
position: 'absolute',
top: 0,
right: 0,
bottom: 0,
left: 0,
borderWidth: 1,
},
trackOff: {
backgroundColor: colors.surfaceElevated,
borderColor: colors.inputBorder,
},
thumb: {
position: 'absolute',
shadowColor: '#000000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.25,
shadowRadius: 4,
elevation: 3,
},
});
export default Switch;

View File

@@ -2,11 +2,13 @@ export { default as FormField } from './FormField';
export { default as Input } from './Input'; export { default as Input } from './Input';
export { default as PasswordInput } from './PasswordInput'; export { default as PasswordInput } from './PasswordInput';
export { default as SearchInput } from './SearchInput'; export { default as SearchInput } from './SearchInput';
export { default as Switch } from './Switch';
export { default as TextArea } from './TextArea'; export { default as TextArea } from './TextArea';
export type { FieldCounter, FormFieldProps } from './FormField'; export type { FieldCounter, FormFieldProps } from './FormField';
export type { InputProps, InputSize, InputState } from './Input'; export type { InputProps, InputSize, InputState } from './Input';
export type { PasswordInputProps, PasswordStrength } from './PasswordInput'; export type { PasswordInputProps, PasswordStrength } from './PasswordInput';
export type { SearchInputProps } from './SearchInput'; export type { SearchInputProps } from './SearchInput';
export type { SwitchProps, SwitchSize } from './Switch';
export type { TextAreaProps } from './TextArea'; export type { TextAreaProps } from './TextArea';

View File

@@ -0,0 +1,619 @@
import * as Haptics from 'expo-haptics';
import { useCallback, useEffect, useMemo, useRef } from 'react';
import {
LayoutChangeEvent,
StyleSheet,
Text,
View,
type AccessibilityActionEvent,
} from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, {
runOnJS,
useAnimatedStyle,
useSharedValue,
withSpring,
} from 'react-native-reanimated';
import { colors } from '@/theme/colors';
import { sliderMetrics, sliderMotion, spacing } from '@/theme/spacing';
import { typography } from '@/theme/typography';
import { SliderFill, SliderThumb, SliderTrack, sliderSizes } from './sliderParts';
export type RangeSliderProps = {
value: [number, number];
onChange: (v: [number, number]) => void;
onChangeEnd?: (v: [number, number]) => void;
min?: number;
max?: number;
step?: number;
minGap?: number;
label?: string;
formatValue?: (v: number) => string;
formatRange?: (a: number, b: number) => string;
showBounds?: boolean;
ticks?: number[];
disabled?: boolean;
haptics?: boolean;
};
const accessibilityActions = [{ name: 'increment' }, { name: 'decrement' }] as const;
type ThumbKey = 'min' | 'max';
function clamp(value: number, min: number, max: number): number {
'worklet';
return Math.min(max, Math.max(min, value));
}
function safeStep(step: number): number {
return Number.isFinite(step) && step > 0 ? step : 1;
}
function quantize(value: number, min: number, max: number, step: number): number {
'worklet';
const q = Math.round((value - min) / step) * step + min;
return clamp(q, min, max);
}
function valueToX(value: number, min: number, max: number, trackWidth: number): number {
'worklet';
if (trackWidth <= 0 || max <= min) {
return 0;
}
const ratio = (value - min) / (max - min);
return clamp(ratio, 0, 1) * trackWidth;
}
function xToValue(x: number, min: number, max: number, step: number, trackWidth: number): number {
'worklet';
if (trackWidth <= 0 || max <= min) {
return min;
}
const clampedX = clamp(x, 0, trackWidth);
const raw = min + (clampedX / trackWidth) * (max - min);
return quantize(raw, min, max, step);
}
function normalizeRange(
range: [number, number],
min: number,
max: number,
step: number,
minGap: number,
): [number, number] {
const start = quantize(range[0], min, max, step);
const end = quantize(range[1], min, max, step);
const boundedMinGap = Math.max(minGap, step);
const normalizedStart = clamp(start, min, end - boundedMinGap);
const normalizedEnd = clamp(end, normalizedStart + boundedMinGap, max);
return [normalizedStart, normalizedEnd];
}
export function RangeSlider({
value,
onChange,
onChangeEnd,
min = 0,
max = 100,
step = 1,
minGap = 1,
label,
formatValue,
formatRange,
showBounds = false,
ticks,
disabled = false,
haptics = true,
}: RangeSliderProps) {
const normalizedStep = safeStep(step);
const boundedMinGap = Math.max(minGap, normalizedStep);
const formatSingle = formatValue ?? ((v: number) => String(v));
const formatPair = formatRange ?? ((a: number, b: number) => `${a} – ${b}`);
const normalizedRange = useMemo(
() => normalizeRange(value, min, max, normalizedStep, boundedMinGap),
[boundedMinGap, max, min, normalizedStep, value],
);
const trackWidth = useSharedValue(0);
const minX = useSharedValue(0);
const maxX = useSharedValue(0);
const minValue = useSharedValue(normalizedRange[0]);
const maxValue = useSharedValue(normalizedRange[1]);
const startX = useSharedValue(0);
const activeThumb = useSharedValue<0 | 1 | 2>(0);
const minScale = useSharedValue(1);
const maxScale = useSharedValue(1);
const lastReportedRef = useRef<[number, number]>(normalizedRange);
const emitHaptic = useCallback(() => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => undefined);
}, []);
const emitChange = useCallback(
(nextMin: number, nextMax: number) => {
const [prevMin, prevMax] = lastReportedRef.current;
if (prevMin === nextMin && prevMax === nextMax) {
return;
}
lastReportedRef.current = [nextMin, nextMax];
onChange([nextMin, nextMax]);
if (haptics) {
emitHaptic();
}
},
[emitHaptic, haptics, onChange],
);
const emitChangeEnd = useCallback(
(nextMin: number, nextMax: number) => {
onChangeEnd?.([nextMin, nextMax]);
},
[onChangeEnd],
);
const syncFromProp = useCallback(() => {
if (trackWidth.value <= 0 || activeThumb.value !== 0) {
minValue.value = normalizedRange[0];
maxValue.value = normalizedRange[1];
return;
}
const nextMinX = valueToX(normalizedRange[0], min, max, trackWidth.value);
const nextMaxX = valueToX(normalizedRange[1], min, max, trackWidth.value);
minValue.value = normalizedRange[0];
maxValue.value = normalizedRange[1];
minX.value = withSpring(nextMinX, {
damping: sliderMotion.springDamping,
stiffness: sliderMotion.springStiffness,
mass: sliderMotion.springMass,
});
maxX.value = withSpring(nextMaxX, {
damping: sliderMotion.springDamping,
stiffness: sliderMotion.springStiffness,
mass: sliderMotion.springMass,
});
}, [activeThumb, max, maxValue, maxX, min, minValue, minX, normalizedRange, trackWidth]);
useEffect(() => {
lastReportedRef.current = normalizedRange;
syncFromProp();
}, [normalizedRange, syncFromProp]);
const onTrackLayout = useCallback(
(event: LayoutChangeEvent) => {
const nextWidth = event.nativeEvent.layout.width;
trackWidth.value = nextWidth;
minValue.value = normalizedRange[0];
maxValue.value = normalizedRange[1];
minX.value = valueToX(normalizedRange[0], min, max, nextWidth);
maxX.value = valueToX(normalizedRange[1], min, max, nextWidth);
},
[max, maxValue, maxX, min, minValue, minX, normalizedRange, trackWidth],
);
const minPan = Gesture.Pan()
.enabled(!disabled)
.hitSlop({
left: sliderSizes.thumbHitSlop,
right: sliderSizes.thumbHitSlop,
top: sliderSizes.thumbHitSlop,
bottom: sliderSizes.thumbHitSlop,
})
.onStart((event) => {
if (trackWidth.value <= 0) {
return;
}
const distanceBetweenThumbs = Math.abs(maxX.value - minX.value);
const touchX = event.x;
const minCenter = minX.value;
const maxCenter = maxX.value;
const distToMin = Math.abs(touchX - minCenter);
const distToMax = Math.abs(touchX - maxCenter);
const draggableRadius = sliderSizes.thumbSize / 2 + sliderSizes.thumbHitSlop;
if (distToMin > draggableRadius) {
return;
}
if (distanceBetweenThumbs < sliderMetrics.closeThumbThresholdPx && distToMin > distToMax) {
return;
}
activeThumb.value = 1;
startX.value = minX.value;
minScale.value = withSpring(sliderMotion.activeScale, {
damping: sliderMotion.springDamping,
stiffness: sliderMotion.springStiffness,
mass: sliderMotion.springMass,
});
})
.onUpdate((event) => {
if (activeThumb.value !== 1 || trackWidth.value <= 0) {
return;
}
const limitMaxValue = maxValue.value - boundedMinGap;
const rawX = clamp(startX.value + event.translationX, 0, trackWidth.value);
const rawValue = xToValue(rawX, min, max, normalizedStep, trackWidth.value);
const nextMinValue = clamp(rawValue, min, limitMaxValue);
const snappedX = valueToX(nextMinValue, min, max, trackWidth.value);
if (nextMinValue !== minValue.value) {
minValue.value = nextMinValue;
minX.value = snappedX;
runOnJS(emitChange)(nextMinValue, maxValue.value);
}
})
.onEnd(() => {
if (activeThumb.value !== 1) {
return;
}
activeThumb.value = 0;
minScale.value = withSpring(1, {
damping: sliderMotion.springDamping,
stiffness: sliderMotion.springStiffness,
mass: sliderMotion.springMass,
});
runOnJS(emitChangeEnd)(minValue.value, maxValue.value);
})
.onFinalize(() => {
if (activeThumb.value === 1) {
activeThumb.value = 0;
}
minScale.value = withSpring(1, {
damping: sliderMotion.springDamping,
stiffness: sliderMotion.springStiffness,
mass: sliderMotion.springMass,
});
});
const maxPan = Gesture.Pan()
.enabled(!disabled)
.hitSlop({
left: sliderSizes.thumbHitSlop,
right: sliderSizes.thumbHitSlop,
top: sliderSizes.thumbHitSlop,
bottom: sliderSizes.thumbHitSlop,
})
.onStart((event) => {
if (trackWidth.value <= 0) {
return;
}
const distanceBetweenThumbs = Math.abs(maxX.value - minX.value);
const touchX = event.x;
const minCenter = minX.value;
const maxCenter = maxX.value;
const distToMin = Math.abs(touchX - minCenter);
const distToMax = Math.abs(touchX - maxCenter);
const draggableRadius = sliderSizes.thumbSize / 2 + sliderSizes.thumbHitSlop;
if (distToMax > draggableRadius) {
return;
}
if (distanceBetweenThumbs < sliderMetrics.closeThumbThresholdPx && distToMax > distToMin) {
return;
}
activeThumb.value = 2;
startX.value = maxX.value;
maxScale.value = withSpring(sliderMotion.activeScale, {
damping: sliderMotion.springDamping,
stiffness: sliderMotion.springStiffness,
mass: sliderMotion.springMass,
});
})
.onUpdate((event) => {
if (activeThumb.value !== 2 || trackWidth.value <= 0) {
return;
}
const limitMinValue = minValue.value + boundedMinGap;
const rawX = clamp(startX.value + event.translationX, 0, trackWidth.value);
const rawValue = xToValue(rawX, min, max, normalizedStep, trackWidth.value);
const nextMaxValue = clamp(rawValue, limitMinValue, max);
const snappedX = valueToX(nextMaxValue, min, max, trackWidth.value);
if (nextMaxValue !== maxValue.value) {
maxValue.value = nextMaxValue;
maxX.value = snappedX;
runOnJS(emitChange)(minValue.value, nextMaxValue);
}
})
.onEnd(() => {
if (activeThumb.value !== 2) {
return;
}
activeThumb.value = 0;
maxScale.value = withSpring(1, {
damping: sliderMotion.springDamping,
stiffness: sliderMotion.springStiffness,
mass: sliderMotion.springMass,
});
runOnJS(emitChangeEnd)(minValue.value, maxValue.value);
})
.onFinalize(() => {
if (activeThumb.value === 2) {
activeThumb.value = 0;
}
maxScale.value = withSpring(1, {
damping: sliderMotion.springDamping,
stiffness: sliderMotion.springStiffness,
mass: sliderMotion.springMass,
});
});
const tapGesture = Gesture.Tap()
.enabled(!disabled)
.onStart((event) => {
if (trackWidth.value <= 0) {
return;
}
const tapX = clamp(event.x, 0, trackWidth.value);
const distToMin = Math.abs(tapX - minX.value);
const distToMax = Math.abs(tapX - maxX.value);
const target: ThumbKey = distToMin <= distToMax ? 'min' : 'max';
const tappedValue = xToValue(tapX, min, max, normalizedStep, trackWidth.value);
if (target === 'min') {
const nextMin = clamp(tappedValue, min, maxValue.value - boundedMinGap);
minValue.value = nextMin;
minX.value = withSpring(valueToX(nextMin, min, max, trackWidth.value), {
damping: sliderMotion.springDamping,
stiffness: sliderMotion.springStiffness,
mass: sliderMotion.springMass,
});
runOnJS(emitChange)(nextMin, maxValue.value);
runOnJS(emitChangeEnd)(nextMin, maxValue.value);
return;
}
const nextMax = clamp(tappedValue, minValue.value + boundedMinGap, max);
maxValue.value = nextMax;
maxX.value = withSpring(valueToX(nextMax, min, max, trackWidth.value), {
damping: sliderMotion.springDamping,
stiffness: sliderMotion.springStiffness,
mass: sliderMotion.springMass,
});
runOnJS(emitChange)(minValue.value, nextMax);
runOnJS(emitChangeEnd)(minValue.value, nextMax);
});
const gesture = Gesture.Simultaneous(minPan, maxPan, tapGesture);
const fillStyle = useAnimatedStyle(() => ({
left: minX.value,
width: Math.max(maxX.value - minX.value, 0),
}));
const minThumbStyle = useAnimatedStyle(() => ({
transform: [{ translateX: minX.value }, { scale: minScale.value }],
}));
const maxThumbStyle = useAnimatedStyle(() => ({
transform: [{ translateX: maxX.value }, { scale: maxScale.value }],
}));
const visualTicks = useMemo(() => {
if (!ticks || ticks.length === 0 || max <= min) {
return [];
}
return ticks
.map((tickValue) => {
const ratio = (tickValue - min) / (max - min);
const clampedRatio = Math.min(1, Math.max(0, ratio));
return { key: String(tickValue), left: `${clampedRatio * 100}%` };
});
}, [max, min, ticks]);
const applyAccessibilityStep = useCallback(
(thumb: ThumbKey, direction: 1 | -1) => {
const [currentMin, currentMax] = normalizedRange;
if (thumb === 'min') {
const nextMin = clamp(
quantize(currentMin + direction * normalizedStep, min, max, normalizedStep),
min,
currentMax - boundedMinGap,
);
if (nextMin === currentMin) {
return;
}
onChange([nextMin, currentMax]);
onChangeEnd?.([nextMin, currentMax]);
} else {
const nextMax = clamp(
quantize(currentMax + direction * normalizedStep, min, max, normalizedStep),
currentMin + boundedMinGap,
max,
);
if (nextMax === currentMax) {
return;
}
onChange([currentMin, nextMax]);
onChangeEnd?.([currentMin, nextMax]);
}
if (haptics) {
emitHaptic();
}
},
[boundedMinGap, emitHaptic, haptics, max, min, normalizedRange, normalizedStep, onChange, onChangeEnd],
);
const onAccessibilityActionMin = useCallback(
(event: AccessibilityActionEvent) => {
const direction = event.nativeEvent.actionName === 'increment' ? 1 : -1;
applyAccessibilityStep('min', direction);
},
[applyAccessibilityStep],
);
const onAccessibilityActionMax = useCallback(
(event: AccessibilityActionEvent) => {
const direction = event.nativeEvent.actionName === 'increment' ? 1 : -1;
applyAccessibilityStep('max', direction);
},
[applyAccessibilityStep],
);
const trackTop = (sliderSizes.minTouchSize - sliderSizes.trackHeight) / 2;
const thumbTop = (sliderSizes.minTouchSize - sliderSizes.thumbSize) / 2;
return (
<View style={[styles.root, disabled && styles.disabled]}>
{(label || formatPair) ? (
<View style={styles.headerRow}>
<Text style={styles.headerLabel}>{label ?? ''}</Text>
<Text style={styles.headerValue}>
{formatPair(normalizedRange[0], normalizedRange[1])}
</Text>
</View>
) : null}
<GestureDetector gesture={gesture}>
<View onLayout={onTrackLayout} style={styles.railContainer}>
<SliderTrack style={[styles.track, { top: trackTop }]}>
<SliderFill style={[styles.fillBase, fillStyle]} />
</SliderTrack>
{visualTicks.length > 0 ? (
<View pointerEvents="none" style={styles.ticksRow}>
{visualTicks.map((tick) => (
<View key={tick.key} style={[styles.tickMark, { left: tick.left }]} />
))}
</View>
) : null}
<Animated.View
style={[styles.thumbPosition, { top: thumbTop, left: -sliderSizes.thumbRadius }, minThumbStyle]}
accessible
accessibilityRole="adjustable"
accessibilityLabel="Prix minimum"
accessibilityActions={accessibilityActions as never}
onAccessibilityAction={onAccessibilityActionMin}
accessibilityValue={{
min,
max,
now: normalizedRange[0],
text: formatSingle(normalizedRange[0]),
}}
>
<SliderThumb />
</Animated.View>
<Animated.View
style={[styles.thumbPosition, { top: thumbTop, left: -sliderSizes.thumbRadius }, maxThumbStyle]}
accessible
accessibilityRole="adjustable"
accessibilityLabel="Prix maximum"
accessibilityActions={accessibilityActions as never}
onAccessibilityAction={onAccessibilityActionMax}
accessibilityValue={{
min,
max,
now: normalizedRange[1],
text: formatSingle(normalizedRange[1]),
}}
>
<SliderThumb />
</Animated.View>
</View>
</GestureDetector>
{showBounds ? (
<View style={styles.boundsRow}>
<Text style={styles.boundsText}>{formatSingle(min)}</Text>
<Text style={styles.boundsText}>{formatSingle(max)}</Text>
</View>
) : null}
</View>
);
}
const styles = StyleSheet.create({
root: {
gap: spacing[8],
},
disabled: {
opacity: 0.45,
},
headerRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: spacing[8],
},
headerLabel: {
...typography.overlineSm,
color: colors.textSecondary,
flexShrink: 1,
},
headerValue: {
...typography.cardTitle,
color: colors.textPrimary,
},
railContainer: {
minHeight: sliderSizes.minTouchSize,
justifyContent: 'center',
},
track: {
position: 'absolute',
left: 0,
right: 0,
},
fillBase: {
position: 'absolute',
},
thumbPosition: {
position: 'absolute',
},
ticksRow: {
position: 'absolute',
left: 0,
right: 0,
top: sliderSizes.minTouchSize - sliderMetrics.trackHeight,
height: spacing[12],
},
tickMark: {
position: 'absolute',
width: spacing[2],
height: spacing[6],
borderRadius: spacing[2],
marginLeft: -spacing[2] / 2,
backgroundColor: colors.inputBorder,
},
boundsRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
boundsText: {
...typography.metadataSm,
fontFamily: typography.overlineSm.fontFamily,
color: colors.textSecondary,
},
});
export default RangeSlider;

View File

@@ -0,0 +1,415 @@
import * as Haptics from 'expo-haptics';
import { useCallback, useEffect, useMemo, useRef } from 'react';
import {
LayoutChangeEvent,
StyleSheet,
Text,
View,
type AccessibilityActionEvent
} from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, {
runOnJS,
useAnimatedStyle,
useSharedValue,
withSpring,
} from 'react-native-reanimated';
import { colors } from '@/theme/colors';
import { sliderMetrics, sliderMotion, spacing } from '@/theme/spacing';
import { typography } from '@/theme/typography';
import { SliderFill, SliderThumb, SliderTrack, sliderSizes } from './sliderParts';
export type SliderProps = {
value: number;
onChange: (v: number) => void;
onChangeEnd?: (v: number) => void;
min?: number;
max?: number;
step?: number;
label?: string;
formatValue?: (v: number) => string;
showBounds?: boolean;
ticks?: number[];
disabled?: boolean;
haptics?: boolean;
};
const accessibilityActions = [{ name: 'increment' }, { name: 'decrement' }] as const;
function clamp(value: number, min: number, max: number): number {
'worklet';
return Math.min(max, Math.max(min, value));
}
function safeStep(step: number): number {
return Number.isFinite(step) && step > 0 ? step : 1;
}
function quantize(value: number, min: number, max: number, step: number): number {
'worklet';
const q = Math.round((value - min) / step) * step + min;
return clamp(q, min, max);
}
function valueToX(value: number, min: number, max: number, trackWidth: number): number {
'worklet';
if (trackWidth <= 0 || max <= min) {
return 0;
}
const ratio = (value - min) / (max - min);
return clamp(ratio, 0, 1) * trackWidth;
}
function xToValue(x: number, min: number, max: number, step: number, trackWidth: number): number {
'worklet';
if (trackWidth <= 0 || max <= min) {
return min;
}
const clampedX = clamp(x, 0, trackWidth);
const raw = min + (clampedX / trackWidth) * (max - min);
return quantize(raw, min, max, step);
}
export function Slider({
value,
onChange,
onChangeEnd,
min = 0,
max = 100,
step = 1,
label,
formatValue,
showBounds = false,
ticks,
disabled = false,
haptics = true,
}: SliderProps) {
const normalizedStep = safeStep(step);
const format = formatValue ?? ((v: number) => String(v));
const normalizedValue = useMemo(
() => quantize(value, min, max, normalizedStep),
[max, min, normalizedStep, value],
);
const trackWidth = useSharedValue(0);
const thumbX = useSharedValue(0);
const currentValue = useSharedValue(normalizedValue);
const startX = useSharedValue(0);
const thumbScale = useSharedValue(1);
const isDragging = useSharedValue(false);
const lastReportedRef = useRef(normalizedValue);
const emitHaptic = useCallback(() => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => undefined);
}, []);
const emitChange = useCallback(
(next: number) => {
if (lastReportedRef.current === next) {
return;
}
lastReportedRef.current = next;
onChange(next);
if (haptics) {
emitHaptic();
}
},
[emitHaptic, haptics, onChange],
);
const emitChangeEnd = useCallback(
(next: number) => {
onChangeEnd?.(next);
},
[onChangeEnd],
);
const syncFromProp = useCallback(() => {
if (trackWidth.value <= 0 || isDragging.value) {
currentValue.value = normalizedValue;
return;
}
const x = valueToX(normalizedValue, min, max, trackWidth.value);
currentValue.value = normalizedValue;
thumbX.value = withSpring(x, {
damping: sliderMotion.springDamping,
stiffness: sliderMotion.springStiffness,
mass: sliderMotion.springMass,
});
}, [currentValue, isDragging, max, min, normalizedValue, thumbX, trackWidth]);
useEffect(() => {
lastReportedRef.current = normalizedValue;
syncFromProp();
}, [normalizedValue, syncFromProp]);
const onTrackLayout = useCallback(
(event: LayoutChangeEvent) => {
const nextWidth = event.nativeEvent.layout.width;
trackWidth.value = nextWidth;
currentValue.value = normalizedValue;
thumbX.value = valueToX(normalizedValue, min, max, nextWidth);
},
[currentValue, max, min, normalizedValue, thumbX, trackWidth],
);
const panGesture = Gesture.Pan()
.enabled(!disabled)
.hitSlop({
left: sliderSizes.thumbHitSlop,
right: sliderSizes.thumbHitSlop,
top: sliderSizes.thumbHitSlop,
bottom: sliderSizes.thumbHitSlop,
})
.onStart((event) => {
if (trackWidth.value <= 0) {
return;
}
const centerX = thumbX.value;
const distance = Math.abs(event.x - centerX);
const draggableRadius = sliderSizes.thumbSize / 2 + sliderSizes.thumbHitSlop;
if (distance > draggableRadius) {
isDragging.value = false;
return;
}
isDragging.value = true;
startX.value = thumbX.value;
thumbScale.value = withSpring(sliderMotion.activeScale, {
damping: sliderMotion.springDamping,
stiffness: sliderMotion.springStiffness,
mass: sliderMotion.springMass,
});
})
.onUpdate((event) => {
if (!isDragging.value || trackWidth.value <= 0) {
return;
}
const rawX = clamp(startX.value + event.translationX, 0, trackWidth.value);
const nextValue = xToValue(rawX, min, max, normalizedStep, trackWidth.value);
const snappedX = valueToX(nextValue, min, max, trackWidth.value);
if (nextValue !== currentValue.value) {
currentValue.value = nextValue;
thumbX.value = snappedX;
runOnJS(emitChange)(nextValue);
}
})
.onEnd(() => {
if (!isDragging.value) {
return;
}
isDragging.value = false;
thumbScale.value = withSpring(1, {
damping: sliderMotion.springDamping,
stiffness: sliderMotion.springStiffness,
mass: sliderMotion.springMass,
});
runOnJS(emitChangeEnd)(currentValue.value);
})
.onFinalize(() => {
isDragging.value = false;
thumbScale.value = withSpring(1, {
damping: sliderMotion.springDamping,
stiffness: sliderMotion.springStiffness,
mass: sliderMotion.springMass,
});
});
const tapGesture = Gesture.Tap()
.enabled(!disabled)
.onStart((event) => {
if (trackWidth.value <= 0) {
return;
}
const nextValue = xToValue(event.x, min, max, normalizedStep, trackWidth.value);
const snappedX = valueToX(nextValue, min, max, trackWidth.value);
currentValue.value = nextValue;
thumbX.value = withSpring(snappedX, {
damping: sliderMotion.springDamping,
stiffness: sliderMotion.springStiffness,
mass: sliderMotion.springMass,
});
runOnJS(emitChange)(nextValue);
runOnJS(emitChangeEnd)(nextValue);
});
const gesture = Gesture.Race(panGesture, tapGesture);
const fillStyle = useAnimatedStyle(() => ({
width: thumbX.value,
}));
const thumbStyle = useAnimatedStyle(() => ({
transform: [{ translateX: thumbX.value }, { scale: thumbScale.value }],
}));
const visualTicks = useMemo(() => {
if (!ticks || ticks.length === 0 || max <= min) {
return [];
}
return ticks
.map((tickValue) => {
const ratio = (tickValue - min) / (max - min);
const clampedRatio = Math.min(1, Math.max(0, ratio));
return { key: String(tickValue), left: `${clampedRatio * 100}%` };
});
}, [max, min, ticks]);
const handleAccessibilityAction = useCallback(
(event: AccessibilityActionEvent) => {
const direction = event.nativeEvent.actionName === 'increment' ? 1 : -1;
const next = quantize(
normalizedValue + direction * normalizedStep,
min,
max,
normalizedStep,
);
if (next === normalizedValue) {
return;
}
onChange(next);
onChangeEnd?.(next);
if (haptics) {
emitHaptic();
}
},
[emitHaptic, haptics, max, min, normalizedStep, normalizedValue, onChange, onChangeEnd],
);
const trackTop = (sliderSizes.minTouchSize - sliderSizes.trackHeight) / 2;
const thumbTop = (sliderSizes.minTouchSize - sliderSizes.thumbSize) / 2;
return (
<View style={[styles.root, disabled && styles.disabled]}>
{(label || format) ? (
<View style={styles.headerRow}>
<Text style={styles.headerLabel}>{label ?? ''}</Text>
<Text style={styles.headerValue}>{format(normalizedValue)}</Text>
</View>
) : null}
<GestureDetector gesture={gesture}>
<View
onLayout={onTrackLayout}
style={styles.railContainer}
accessible
accessibilityRole="adjustable"
accessibilityLabel={label ?? 'Curseur'}
accessibilityActions={accessibilityActions as never}
onAccessibilityAction={handleAccessibilityAction}
accessibilityValue={{
min,
max,
now: normalizedValue,
text: format(normalizedValue),
}}
>
<SliderTrack style={[styles.track, { top: trackTop }]}>
<SliderFill style={fillStyle} />
</SliderTrack>
{visualTicks.length > 0 ? (
<View pointerEvents="none" style={styles.ticksRow}>
{visualTicks.map((tick) => (
<View key={tick.key} style={[styles.tickMark, { left: tick.left }]} />
))}
</View>
) : null}
<Animated.View style={[styles.thumbPosition, { top: thumbTop }, thumbStyle]}>
<SliderThumb />
</Animated.View>
</View>
</GestureDetector>
{showBounds ? (
<View style={styles.boundsRow}>
<Text style={styles.boundsText}>{format(min)}</Text>
<Text style={styles.boundsText}>{format(max)}</Text>
</View>
) : null}
</View>
);
}
const styles = StyleSheet.create({
root: {
gap: spacing[8],
},
disabled: {
opacity: 0.45,
},
headerRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: spacing[8],
},
headerLabel: {
...typography.overlineSm,
color: colors.textSecondary,
flexShrink: 1,
},
headerValue: {
...typography.cardTitle,
color: colors.textPrimary,
},
railContainer: {
minHeight: sliderSizes.minTouchSize,
justifyContent: 'center',
},
track: {
position: 'absolute',
left: 0,
right: 0,
},
thumbPosition: {
position: 'absolute',
left: -sliderSizes.thumbRadius,
},
ticksRow: {
position: 'absolute',
left: 0,
right: 0,
top: sliderSizes.minTouchSize - sliderMetrics.trackHeight,
height: spacing[12],
},
tickMark: {
position: 'absolute',
width: spacing[2],
height: spacing[6],
borderRadius: spacing[2],
marginLeft: -spacing[2] / 2,
backgroundColor: colors.inputBorder,
},
boundsRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
boundsText: {
...typography.metadataSm,
fontFamily: typography.overlineSm.fontFamily,
color: colors.textSecondary,
},
});
export default Slider;

View File

@@ -0,0 +1,8 @@
export { Header } from './Header';
export { RangeSlider } from './RangeSlider';
export { Screen } from './Screen';
export { Slider } from './Slider';
export type { RangeSliderProps } from './RangeSlider';
export type { SliderProps } from './Slider';

View File

@@ -0,0 +1,80 @@
import { LinearGradient } from 'expo-linear-gradient';
import { StyleSheet, View, type StyleProp, type ViewStyle } from 'react-native';
import Animated from 'react-native-reanimated';
import { colors, gradientDirection, gradients } from '@/theme/colors';
import { borderRadius, sliderMetrics, sliderShadow } from '@/theme/spacing';
export const sliderSizes = {
trackHeight: sliderMetrics.trackHeight,
thumbSize: sliderMetrics.thumbSize,
thumbRadius: sliderMetrics.thumbSize / 2,
thumbBorderWidth: sliderMetrics.thumbBorderWidth,
minTouchSize: sliderMetrics.minTouchSize,
trackRadius: borderRadius.chip,
thumbHitSlop: (sliderMetrics.minTouchSize - sliderMetrics.thumbSize) / 2,
} as const;
type SliderTrackProps = {
style?: StyleProp<ViewStyle>;
children?: React.ReactNode;
};
export function SliderTrack({ style, children }: SliderTrackProps) {
return <View style={[styles.track, style]}>{children}</View>;
}
type SliderFillProps = {
style?: StyleProp<ViewStyle>;
};
export function SliderFill({ style }: SliderFillProps) {
return (
<Animated.View pointerEvents="none" style={[styles.fillMask, style]}>
<LinearGradient
colors={gradients.signature}
start={gradientDirection.start}
end={gradientDirection.end}
style={styles.fillGradient}
/>
</Animated.View>
);
}
type SliderThumbProps = {
style?: StyleProp<ViewStyle>;
};
export function SliderThumb({ style }: SliderThumbProps) {
return <Animated.View pointerEvents="none" style={[styles.thumb, style]} />;
}
const styles = StyleSheet.create({
track: {
height: sliderSizes.trackHeight,
borderRadius: sliderSizes.trackRadius,
backgroundColor: colors.surfaceElevated,
overflow: 'hidden',
},
fillMask: {
height: sliderSizes.trackHeight,
borderRadius: sliderSizes.trackRadius,
overflow: 'hidden',
},
fillGradient: {
flex: 1,
},
thumb: {
width: sliderSizes.thumbSize,
height: sliderSizes.thumbSize,
borderRadius: sliderSizes.thumbRadius,
borderWidth: sliderSizes.thumbBorderWidth,
borderColor: colors.primary,
backgroundColor: colors.onPrimary,
shadowColor: colors.primary,
shadowOffset: { width: 0, height: 0 },
shadowOpacity: sliderShadow.opacity,
shadowRadius: sliderShadow.radius,
elevation: sliderShadow.elevation,
},
});

View File

@@ -1,10 +1,15 @@
export const spacing = { export const spacing = {
2: 2,
4: 4, 4: 4,
6: 6,
8: 8, 8: 8,
12: 12, 12: 12,
16: 16, 16: 16,
20: 20,
24: 24, 24: 24,
26: 26,
32: 32, 32: 32,
44: 44,
} as const; } as const;
export const space = spacing; export const space = spacing;
@@ -15,4 +20,25 @@ export const borderRadius = {
chip: 9999, chip: 9999,
} as const; } as const;
export const sliderMetrics = {
trackHeight: spacing[6],
thumbSize: spacing[26],
thumbBorderWidth: 3,
minTouchSize: spacing[44],
closeThumbThresholdPx: spacing[24],
} as const;
export const sliderMotion = {
activeScale: 1.18,
springDamping: 20,
springStiffness: 240,
springMass: 0.4,
} as const;
export const sliderShadow = {
opacity: 0.55,
radius: 12,
elevation: 6,
} as const;
export default spacing; export default spacing;