diff --git a/src/components/Slider/Slider.tsx b/src/components/Slider/Slider.tsx new file mode 100644 index 0000000000..7399041654 --- /dev/null +++ b/src/components/Slider/Slider.tsx @@ -0,0 +1,137 @@ +import * as React from 'react'; +import { StyleSheet, View, ViewStyle } from 'react-native'; + +import type { SliderProps } from './types'; +import { useInternalTheme } from '../../core/theming'; + +const clamp = (v: number, min: number, max: number) => + Math.min(Math.max(v, min), max); + +const round = (v: number, step: number) => { + if (!step) return v; + return Math.round(v / step) * step; +}; + +const styles = StyleSheet.create({ + container: { + height: 48, + justifyContent: 'center', + paddingVertical: 12, + } as ViewStyle, + track: { + height: 4, + borderRadius: 2, + overflow: 'hidden', + position: 'relative', + } as ViewStyle, + thumb: { + position: 'absolute', + width: 16, + height: 16, + borderRadius: 8, + top: -6, + marginLeft: -8, + } as ViewStyle, +}); + +/** + * Material 3 Slider. + * + * A horizontal slider that lets the user pick a value between `min` and `max` + * by dragging or tapping the track. The thumb position reflects the + * controlled `value`. + * + * @param props + */ +function Slider({ + value, + min = 0, + max = 1, + step, + onValueChange, + disabled = false, + color, + trackColor, + thumbColor, + style, + theme: themeOverrides, + testID = 'slider', +}: SliderProps) { + const theme = useInternalTheme(themeOverrides); + const [width, setWidth] = React.useState(0); + + const activeColor = + color ?? (theme.isV3 ? theme.colors.primary : theme.colors.accent); + const inactiveColor = + trackColor ?? (theme.isV3 ? theme.colors.surfaceVariant : '#bdbdbd'); + const thumbTintColor = thumbColor ?? activeColor; + + const span = Math.max(max - min, 1e-9); + const pct = clamp((value - min) / span, 0, 1); + + const valueFromX = (x: number) => { + const ratio = width > 0 ? clamp(x / width, 0, 1) : 0; + let next = min + ratio * span; + if (step) next = min + round(next - min, step); + return clamp(next, min, max); + }; + + const handleResponder = (e: { nativeEvent: { locationX: number } }) => { + if (disabled || width <= 0) return; + onValueChange?.(valueFromX(e.nativeEvent.locationX)); + }; + + const handleLayout = (e: { nativeEvent: { layout: { width: number } } }) => { + setWidth(e.nativeEvent.layout.width); + }; + + return ( + + !disabled} + onResponderGrant={handleResponder} + onResponderMove={handleResponder} + > + + + + + ); +} + +export default Slider; diff --git a/src/components/Slider/types.ts b/src/components/Slider/types.ts new file mode 100644 index 0000000000..8e863c725b --- /dev/null +++ b/src/components/Slider/types.ts @@ -0,0 +1,30 @@ +import type { StyleProp, ViewStyle } from 'react-native'; + +import type { ThemeProp } from '../../types'; + +export type SliderProps = { + /** Current value of the slider (controlled). */ + value: number; + /** Minimum value. @default 0 */ + min?: number; + /** Maximum value. @default 1 */ + max?: number; + /** Step size. When set, the value snaps to multiples of `step` from `min`. */ + step?: number; + /** Called with the new value whenever the user drags or taps the track. */ + onValueChange?: (value: number) => void; + /** Whether the slider is disabled (no interaction, dimmed thumb). */ + disabled?: boolean; + /** Color of the active track (left of the thumb). Defaults to theme primary. */ + color?: string; + /** Color of the inactive track. Defaults to a translucent track. */ + trackColor?: string; + /** Color of the thumb. Defaults to the active color. */ + thumbColor?: string; + /** Style applied to the container. */ + style?: StyleProp; + /** @optional */ + theme?: ThemeProp; + /** TestID for testing. */ + testID?: string; +}; diff --git a/src/index.tsx b/src/index.tsx index 1b20528787..b399a14759 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -33,6 +33,7 @@ export { default as BottomNavigation } from './components/BottomNavigation/Botto export { default as Button } from './components/Button/Button'; export { default as Card } from './components/Card/Card'; export { default as Checkbox } from './components/Checkbox'; +export { default as Slider } from './components/Slider/Slider'; export { default as Chip } from './components/Chip/Chip'; export { default as DataTable } from './components/DataTable/DataTable'; export { default as Dialog } from './components/Dialog/Dialog'; @@ -86,6 +87,7 @@ export type { } from './components/BottomNavigation/BottomNavigation'; export type { Props as ButtonProps } from './components/Button/Button'; export type { Props as CardProps } from './components/Card/Card'; +export type { SliderProps } from './components/Slider/types'; export type { Props as CardActionsProps } from './components/Card/CardActions'; export type { Props as CardContentProps } from './components/Card/CardContent'; export type { Props as CardCoverProps } from './components/Card/CardCover';