diff --git a/example/src/ExampleList.tsx b/example/src/ExampleList.tsx index 18c958a4bb..85ea0b6228 100644 --- a/example/src/ExampleList.tsx +++ b/example/src/ExampleList.tsx @@ -36,6 +36,7 @@ import SearchbarExample from './Examples/SearchbarExample'; import SegmentedButtonMultiselectRealCase from './Examples/SegmentedButtons/SegmentedButtonMultiselectRealCase'; import SegmentedButtonRealCase from './Examples/SegmentedButtons/SegmentedButtonRealCase'; import SegmentedButtonExample from './Examples/SegmentedButtonsExample'; +import SliderExample from './Examples/SliderExample'; import SnackbarExample from './Examples/SnackbarExample'; import SurfaceExample from './Examples/SurfaceExample'; import SwitchExample from './Examples/SwitchExample'; @@ -79,6 +80,7 @@ export const mainExamples = { RadioItem: RadioButtonItemExample, Searchbar: SearchbarExample, SegmentedButton: SegmentedButtonExample, + Slider: SliderExample, Snackbar: SnackbarExample, Surface: SurfaceExample, Switch: SwitchExample, diff --git a/example/src/Examples/SliderExample.tsx b/example/src/Examples/SliderExample.tsx new file mode 100644 index 0000000000..76cb3d052d --- /dev/null +++ b/example/src/Examples/SliderExample.tsx @@ -0,0 +1,240 @@ +import * as React from 'react'; +import { StyleSheet, View } from 'react-native'; + +import { Slider, Switch, Text, useTheme } from 'react-native-paper'; + +import ScreenWrapper from '../ScreenWrapper'; + +type SliderSize = 'xs' | 's' | 'm' | 'l' | 'xl'; +const SIZES: SliderSize[] = ['xs', 's', 'm', 'l', 'xl']; + +const Section = ({ + title, + children, +}: { + title: string; + children: React.ReactNode; +}) => { + const theme = useTheme(); + return ( + + + {title} + + {children} + + ); +}; + +const Row = ({ + label, + children, +}: { + label: string; + children: React.ReactNode; +}) => ( + + {label} + {children} + +); + +const SliderExample = () => { + const theme = useTheme(); + + const [standardValue, setStandardValue] = React.useState(40); + const [centeredValue, setCenteredValue] = React.useState(0); + const [rangeValue, setRangeValue] = React.useState<[number, number]>([ + 20, 75, + ]); + + const [size, setSize] = React.useState('m'); + const [vertical, setVertical] = React.useState(false); + const [showIcon, setShowIcon] = React.useState(false); + const [showStops, setShowStops] = React.useState(false); + const [showValueIndicator, setShowValueIndicator] = React.useState(false); + const [disabled, setDisabled] = React.useState(false); + + const iconSource = showIcon ? 'volume-high' : undefined; + const orientation = vertical ? 'vertical' : 'horizontal'; + + return ( + + {/* Controls */} +
+ + + {SIZES.map((s) => ( + setSize(s)} + style={[ + styles.sizeChip, + { + backgroundColor: + size === s + ? theme.colors.primaryContainer + : theme.colors.surfaceVariant, + color: + size === s + ? theme.colors.onPrimaryContainer + : theme.colors.onSurfaceVariant, + }, + ]} + > + {s.toUpperCase()} + + ))} + + + + + + + + + + + + + + + + + +
+ + {/* Sliders */} +
+ + + + Value: {standardValue} +
+ +
+ + + + Value: {centeredValue} +
+ +
+ + + + + Range: {rangeValue[0]} - {rangeValue[1]} + +
+
+ ); +}; + +SliderExample.title = 'Slider'; + +const styles = StyleSheet.create({ + container: { + paddingVertical: 8, + }, + section: { + marginBottom: 24, + paddingHorizontal: 16, + }, + sectionTitle: { + marginBottom: 12, + textTransform: 'uppercase', + letterSpacing: 0.5, + }, + row: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingVertical: 6, + }, + label: { + flex: 1, + }, + sizeRow: { + flexDirection: 'row', + gap: 6, + }, + sizeChip: { + paddingHorizontal: 10, + paddingVertical: 4, + borderRadius: 8, + overflow: 'hidden', + fontSize: 12, + fontWeight: '600', + }, + sliderArea: { + marginTop: 8, + marginBottom: 4, + }, + sliderAreaVertical: { + height: 200, + alignItems: 'flex-start', + }, + slider: { + flex: 1, + }, + sliderVertical: { + height: '100%', + }, + valueText: { + opacity: 0.6, + fontSize: 13, + marginTop: 4, + }, +}); + +export default SliderExample; diff --git a/src/components/Slider/Slider.tsx b/src/components/Slider/Slider.tsx new file mode 100644 index 0000000000..fb2a7b1d9f --- /dev/null +++ b/src/components/Slider/Slider.tsx @@ -0,0 +1,965 @@ +import * as React from 'react'; +import { + PanResponder, + StyleSheet, + View, + type ColorValue, + type StyleProp, + type ViewStyle, +} from 'react-native'; + +import Animated, { + ReduceMotion, + useAnimatedStyle, + useSharedValue, + withTiming, +} from 'react-native-reanimated'; + +import { + BETWEEN_HANDLE_SPACE, + DISABLED_CONTENT_OPACITY, + DISABLED_INACTIVE_OPACITY, + INNER_CORNER_RADIUS, + SIZE_SPECS, + STOP_SIZE, + VALUE_INDICATOR_BOTTOM_SPACE, + VALUE_INDICATOR_SIZE, + SliderTokens, + type SliderSize, +} from './tokens'; +import { + fractionToValue, + nearestHandle, + positionToFraction, + stopFractions, + valueToFraction, +} from './utils'; +import { useLocale } from '../../core/locale'; +import { useInternalTheme } from '../../core/theming'; +import { useReduceMotion } from '../../theme/accessibility/ReduceMotionContext'; +import { cornerFull } from '../../theme/tokens/sys/shape'; +import type { ThemeProp } from '../../types'; +import useLayout from '../../utils/useLayout'; +import Icon, { type IconSource } from '../Icon'; +import Text from '../Typography/Text'; + +type BaseProps = { + min?: number; + max?: number; + step?: number; + size?: SliderSize; + orientation?: 'horizontal' | 'vertical'; + disabled?: boolean; + icon?: IconSource; + showStops?: boolean; + showValueIndicator?: boolean; + valueIndicatorLabel?: (v: number) => string; + color?: ColorValue; + style?: StyleProp; + testID?: string; + theme?: ThemeProp; + accessibilityLabel?: string; +}; + +type StandardProps = BaseProps & { + variant?: 'standard'; + value: number; + onValueChange?: (v: number) => void; + onSlidingStart?: (v: number) => void; + onSlidingComplete?: (v: number) => void; +}; + +type CenteredProps = BaseProps & { + variant: 'centered'; + value: number; + onValueChange?: (v: number) => void; + onSlidingStart?: (v: number) => void; + onSlidingComplete?: (v: number) => void; +}; + +type RangeProps = BaseProps & { + variant: 'range'; + value: [number, number]; + onValueChange?: (v: [number, number]) => void; + onSlidingStart?: (v: [number, number]) => void; + onSlidingComplete?: (v: [number, number]) => void; +}; + +export type Props = StandardProps | CenteredProps | RangeProps; + +/** + * Material 3 slider for selecting a value from a range. + * Supports standard, centered, and range variants. + * + * ## Usage + * ```js + * import * as React from 'react'; + * import { Slider } from 'react-native-paper'; + * + * const Example = () => { + * const [value, setValue] = React.useState(50); + * return ; + * }; + * ``` + */ +const Slider = (props: Props) => { + const { + min = 0, + max = 100, + step = 0, + size = 's', + orientation = 'horizontal', + disabled = false, + icon, + showStops = false, + showValueIndicator = false, + valueIndicatorLabel, + color, + style, + testID, + theme: themeOverrides, + accessibilityLabel, + } = props; + + const variant = props.variant ?? 'standard'; + const isRange = variant === 'range'; + + const theme = useInternalTheme(themeOverrides); + const reduceMotion = useReduceMotion(); + const { direction } = useLocale(); + const isRTL = direction === 'rtl'; + const isVertical = orientation === 'vertical'; + + const spec = SIZE_SPECS[size]; + + const colors = React.useMemo(() => { + const t = SliderTokens.colors; + const c = theme.colors; + return { + activeTrack: color ?? c[t.activeTrack], + inactiveTrack: c[t.inactiveTrack], + handle: color ?? c[t.handle], + stopOnActive: c[t.stopOnActive], + stopOnInactive: c[t.stopOnInactive], + valueIndicatorBg: c[t.valueIndicatorBg], + valueIndicatorText: c[t.valueIndicatorText], + disabledContent: c[t.disabledContent], + }; + }, [theme, color]); + + const reanimatedReduceMotion = reduceMotion + ? ReduceMotion.Always + : ReduceMotion.Never; + + const timingConfig = React.useMemo( + () => ({ duration: 100, reduceMotion: reanimatedReduceMotion }), + [reanimatedReduceMotion] + ); + + const initialEndValue = isRange + ? (props as RangeProps).value[1] + : (props as StandardProps | CenteredProps).value; + const initialStartValue = isRange ? (props as RangeProps).value[0] : min; + + const [endValue, setEndValue] = React.useState(initialEndValue); + const [startValue, setStartValue] = React.useState(initialStartValue); + const [indicatorDisplayValue, setIndicatorDisplayValue] = + React.useState(initialEndValue); + + React.useEffect(() => { + if (isRange) { + const [s, e] = (props as RangeProps).value; + setStartValue(s); + setEndValue(e); + } else { + const v = (props as StandardProps | CenteredProps).value; + setEndValue(v); + } + }, [props, isRange]); + + const [layout, onLayout] = useLayout(); + const trackLength = isVertical ? layout.height : layout.width; + + // Extra space above the track for the value indicator bubble (horizontal only) + const verticalOffset = + showValueIndicator && !isVertical + ? VALUE_INDICATOR_SIZE + VALUE_INDICATOR_BOTTOM_SPACE + : 0; + + const trackLengthSV = useSharedValue(0); + const endFractionSV = useSharedValue(valueToFraction(endValue, min, max)); + const startFractionSV = useSharedValue(valueToFraction(startValue, min, max)); + const endHandleWidthSV = useSharedValue(spec.handleWidth); + const startHandleWidthSV = useSharedValue(spec.handleWidth); + const valueIndicatorAlphaSV = useSharedValue(0); + + React.useEffect(() => { + trackLengthSV.value = trackLength; + }, [trackLength, trackLengthSV]); + + React.useEffect(() => { + if (!isRange) { + endFractionSV.value = valueToFraction(endValue, min, max); + } + }, [endValue, min, max, isRange, endFractionSV]); + + React.useEffect(() => { + if (isRange) { + startFractionSV.value = valueToFraction(startValue, min, max); + endFractionSV.value = valueToFraction(endValue, min, max); + } + }, [startValue, endValue, min, max, isRange, startFractionSV, endFractionSV]); + + // gap = half handle width + between-handle space + const trackHandleOffset = spec.handleWidth / 2 + BETWEEN_HANDLE_SPACE; + + // Active track: gaps on handle sides, 2dp inner corners + const activeTrackAnimStyle = useAnimatedStyle(() => { + const vf = endFractionSV.value; + const sf = startFractionSV.value; + const len = trackLengthSV.value; + const gap = trackHandleOffset; + + if (isVertical) { + let bottom: number; + let height: number; + + if (variant === 'range') { + const lo = Math.min(sf, vf); + const hi = Math.max(sf, vf); + bottom = lo * len + gap; + height = Math.max(0, (hi - lo) * len - 2 * gap); + } else if (variant === 'centered') { + if (vf >= 0.5) { + bottom = 0.5 * len; + height = Math.max(0, (vf - 0.5) * len - gap); + } else { + bottom = vf * len + gap; + height = Math.max(0, (0.5 - vf) * len - gap); + } + } else { + bottom = 0; + height = Math.max(0, vf * len - gap); + } + + return { + bottom, + height, + top: undefined, + left: 0, + right: 0, + width: undefined, + }; + } + + let left: number; + let width: number; + + if (variant === 'range') { + const lo = Math.min(sf, vf); + const hi = Math.max(sf, vf); + left = lo * len + gap; + width = Math.max(0, (hi - lo) * len - 2 * gap); + } else if (variant === 'centered') { + if (vf >= 0.5) { + left = 0.5 * len; + width = Math.max(0, (vf - 0.5) * len - gap); + } else { + left = vf * len + gap; + width = Math.max(0, (0.5 - vf) * len - gap); + } + } else { + left = 0; + width = Math.max(0, vf * len - gap); + } + + return { + left, + width, + top: 0, + bottom: 0, + right: undefined, + height: undefined, + }; + }); + + // Right inactive track: from (handle + gap) to track end, 2dp inner left corners + const rightInactiveAnimStyle = useAnimatedStyle(() => { + const vf = endFractionSV.value; + const sf = startFractionSV.value; + const len = trackLengthSV.value; + const gap = trackHandleOffset; + + if (isVertical) { + let height: number; + if (variant === 'range') { + const hi = Math.max(sf, vf); + height = Math.max(0, (1 - hi) * len - gap); + } else if (variant === 'centered') { + height = + vf >= 0.5 + ? Math.max(0, (1 - vf) * len - gap) + : Math.max(0, 0.5 * len); + } else { + height = Math.max(0, (1 - vf) * len - gap); + } + return { + top: 0, + height, + bottom: undefined, + left: 0, + right: 0, + width: undefined, + }; + } + + let left: number; + let width: number; + if (variant === 'range') { + const hi = Math.max(sf, vf); + left = hi * len + gap; + width = Math.max(0, (1 - hi) * len - gap); + } else if (variant === 'centered') { + if (vf >= 0.5) { + left = vf * len + gap; + width = Math.max(0, (1 - vf) * len - gap); + } else { + left = 0.5 * len; + width = Math.max(0, 0.5 * len); + } + } else { + left = vf * len + gap; + width = Math.max(0, (1 - vf) * len - gap); + } + return { + left, + width, + top: 0, + bottom: 0, + right: undefined, + height: undefined, + }; + }); + + // Left inactive track: from track start to (handle - gap), 2dp inner right corners + // Only needed for centered and range variants. + const leftInactiveAnimStyle = useAnimatedStyle(() => { + if (variant === 'standard') { + return { width: 0 }; + } + + const vf = endFractionSV.value; + const sf = startFractionSV.value; + const len = trackLengthSV.value; + const gap = trackHandleOffset; + + if (isVertical) { + let height: number; + if (variant === 'range') { + const lo = Math.min(sf, vf); + height = Math.max(0, lo * len - gap); + } else { + // centered + height = + vf >= 0.5 ? Math.max(0, 0.5 * len) : Math.max(0, vf * len - gap); + } + return { + bottom: 0, + height, + top: undefined, + left: 0, + right: 0, + width: undefined, + }; + } + + let width: number; + if (variant === 'range') { + const lo = Math.min(sf, vf); + width = Math.max(0, lo * len - gap); + } else { + // centered + width = vf >= 0.5 ? Math.max(0, 0.5 * len) : Math.max(0, vf * len - gap); + } + return { + left: 0, + width, + top: 0, + bottom: 0, + right: undefined, + height: undefined, + }; + }); + + // End handle animated position along the track axis + const endHandleAnimStyle = useAnimatedStyle(() => { + const len = trackLengthSV.value; + const w = endHandleWidthSV.value; + if (isVertical) { + const pos = (1 - endFractionSV.value) * len; + return { top: pos - w / 2, height: w }; + } + const pos = endFractionSV.value * len; + return { left: pos - w / 2, width: w }; + }); + + // Start handle animated position along the track axis + const startHandleAnimStyle = useAnimatedStyle(() => { + const len = trackLengthSV.value; + const w = startHandleWidthSV.value; + if (isVertical) { + const pos = (1 - startFractionSV.value) * len; + return { top: pos - w / 2, height: w }; + } + const pos = startFractionSV.value * len; + return { left: pos - w / 2, width: w }; + }); + + // Value indicator animated style + const valueIndicatorAnimStyle = useAnimatedStyle(() => { + const len = trackLengthSV.value; + if (isVertical) { + const pos = (1 - endFractionSV.value) * len; + return { + opacity: valueIndicatorAlphaSV.value, + top: pos - VALUE_INDICATOR_SIZE / 2, + left: -(VALUE_INDICATOR_SIZE + VALUE_INDICATOR_BOTTOM_SPACE), + right: undefined, + bottom: undefined, + transform: [], + }; + } + return { + opacity: valueIndicatorAlphaSV.value, + transform: [ + { translateX: endFractionSV.value * len - VALUE_INDICATOR_SIZE / 2 }, + ], + }; + }); + + // Gesture handling + const valuesRef = React.useRef({ + min, + max, + step, + trackLength, + endValue, + startValue, + variant, + isRTL, + isVertical, + }); + valuesRef.current = { + min, + max, + step, + trackLength, + endValue, + startValue, + variant, + isRTL, + isVertical, + }; + + const grantTouchRef = React.useRef(0); + const activeHandleRef = React.useRef<'start' | 'end'>('end'); + + const panResponder = React.useRef( + PanResponder.create({ + onStartShouldSetPanResponder: () => !disabled, + onMoveShouldSetPanResponder: () => !disabled, + + onPanResponderGrant: (evt) => { + const { + min: mn, + max: mx, + isVertical: iv, + isRTL: rtl, + trackLength: tl, + startValue: sv, + endValue: ev, + variant: vt, + } = valuesRef.current; + const touchPx = iv + ? evt.nativeEvent.locationY + : evt.nativeEvent.locationX; + grantTouchRef.current = touchPx; + + if (vt === 'range') { + const f = positionToFraction(touchPx, tl, rtl, iv); + activeHandleRef.current = nearestHandle( + f, + valueToFraction(sv, mn, mx), + valueToFraction(ev, mn, mx) + ); + } else { + activeHandleRef.current = 'end'; + } + + if (activeHandleRef.current === 'start') { + startHandleWidthSV.value = withTiming( + spec.handlePressWidth, + timingConfig + ); + } else { + endHandleWidthSV.value = withTiming( + spec.handlePressWidth, + timingConfig + ); + } + + if (showValueIndicator) { + valueIndicatorAlphaSV.value = withTiming(1, timingConfig); + } + + if (props.onSlidingStart) { + if (isRange) { + (props as RangeProps).onSlidingStart?.([sv, ev]); + } else { + (props as StandardProps | CenteredProps).onSlidingStart?.(ev); + } + } + }, + + onPanResponderMove: (_, gestureState) => { + const { + min: mn, + max: mx, + step: st, + trackLength: tl, + startValue: sv, + endValue: ev, + variant: vt, + isVertical: iv, + isRTL: rtl, + } = valuesRef.current; + + const delta = iv ? gestureState.dy : gestureState.dx; + const touchPx = grantTouchRef.current + delta; + const fraction = positionToFraction(touchPx, tl, rtl, iv); + const snapped = fractionToValue(fraction, mn, mx, st); + // Snap the visual handle position for discrete mode + const displayFraction = + st > 0 ? valueToFraction(snapped, mn, mx) : fraction; + + if (vt === 'range') { + if (activeHandleRef.current === 'start') { + const clamped = Math.min(snapped, ev); + startFractionSV.value = valueToFraction(clamped, mn, mx); + setStartValue(clamped); + setIndicatorDisplayValue(clamped); + (props as RangeProps).onValueChange?.([clamped, ev]); + } else { + const clamped = Math.max(snapped, sv); + endFractionSV.value = valueToFraction(clamped, mn, mx); + setEndValue(clamped); + setIndicatorDisplayValue(clamped); + (props as RangeProps).onValueChange?.([sv, clamped]); + } + } else { + endFractionSV.value = displayFraction; + setEndValue(snapped); + setIndicatorDisplayValue(snapped); + (props as StandardProps | CenteredProps).onValueChange?.(snapped); + } + }, + + onPanResponderRelease: () => { + const { endValue: ev, startValue: sv } = valuesRef.current; + + if (activeHandleRef.current === 'start') { + startHandleWidthSV.value = withTiming(spec.handleWidth, timingConfig); + } else { + endHandleWidthSV.value = withTiming(spec.handleWidth, timingConfig); + } + + if (showValueIndicator) { + valueIndicatorAlphaSV.value = withTiming(0, timingConfig); + } + + if (props.onSlidingComplete) { + if (isRange) { + (props as RangeProps).onSlidingComplete?.([sv, ev]); + } else { + (props as StandardProps | CenteredProps).onSlidingComplete?.(ev); + } + } + }, + + onPanResponderTerminate: () => { + if (activeHandleRef.current === 'start') { + startHandleWidthSV.value = withTiming(spec.handleWidth, timingConfig); + } else { + endHandleWidthSV.value = withTiming(spec.handleWidth, timingConfig); + } + if (showValueIndicator) { + valueIndicatorAlphaSV.value = withTiming(0, timingConfig); + } + }, + }) + ).current; + + // Accessibility + const handleAccessibilityIncrement = () => { + if (disabled) return; + const increment = step > 0 ? step : (max - min) / 100; + const next = Math.min(endValue + increment, max); + setEndValue(next); + endFractionSV.value = valueToFraction(next, min, max); + if (!isRange) { + (props as StandardProps | CenteredProps).onValueChange?.(next); + } + }; + + const handleAccessibilityDecrement = () => { + if (disabled) return; + const decrement = step > 0 ? step : (max - min) / 100; + const next = Math.max(endValue - decrement, min); + setEndValue(next); + endFractionSV.value = valueToFraction(next, min, max); + if (!isRange) { + (props as StandardProps | CenteredProps).onValueChange?.(next); + } + }; + + // End stops are always visible at the track's min/max positions. + // Intermediate step tick marks are only shown when showStops && step > 0, + // and skip fractions 0 and 1 since those are covered by the end stops. + const endStopFractions: number[] = trackLength > 0 ? [0, 1] : []; + const stepTickFractions: number[] = + showStops && step > 0 && trackLength > 0 + ? stopFractions(min, max, step).filter((f) => f > 0.001 && f < 0.999) + : []; + const allStopFractions = [...endStopFractions, ...stepTickFractions]; + + // Active segment bounds for stop color determination + const endFraction = valueToFraction(endValue, min, max); + const startFraction = isRange ? valueToFraction(startValue, min, max) : 0; + const activeLead = + variant === 'centered' + ? Math.min(0.5, endFraction) + : variant === 'range' + ? Math.min(startFraction, endFraction) + : 0; + const activeTrail = + variant === 'centered' + ? Math.max(0.5, endFraction) + : variant === 'range' + ? Math.max(startFraction, endFraction) + : endFraction; + + const indicatorText = valueIndicatorLabel + ? valueIndicatorLabel(indicatorDisplayValue) + : String(Math.round(indicatorDisplayValue)); + + const contentOpacity = disabled ? DISABLED_CONTENT_OPACITY : 1; + const inactiveOpacity = disabled ? DISABLED_INACTIVE_OPACITY : 1; + + // Track container: positioned within the handle area, offset for indicator zone + const trackContainerStyle: ViewStyle = isVertical + ? { + position: 'absolute', + top: 0, + bottom: 0, + left: (spec.handleHeight - spec.trackThickness) / 2, + width: spec.trackThickness, + } + : { + position: 'absolute', + left: 0, + right: 0, + top: verticalOffset + (spec.handleHeight - spec.trackThickness) / 2, + height: spec.trackThickness, + }; + + // Corner radii for each track segment + // Outer corners use the size spec radii; inner corners (facing the handle gap) use 2dp. + const leftInactiveCorners: ViewStyle = isVertical + ? { + borderBottomLeftRadius: spec.activeLeadingRadius, + borderBottomRightRadius: spec.activeLeadingRadius, + borderTopLeftRadius: INNER_CORNER_RADIUS, + borderTopRightRadius: INNER_CORNER_RADIUS, + } + : { + borderTopLeftRadius: spec.activeLeadingRadius, + borderBottomLeftRadius: spec.activeLeadingRadius, + borderTopRightRadius: INNER_CORNER_RADIUS, + borderBottomRightRadius: INNER_CORNER_RADIUS, + }; + + const rightInactiveCorners: ViewStyle = isVertical + ? { + borderBottomLeftRadius: INNER_CORNER_RADIUS, + borderBottomRightRadius: INNER_CORNER_RADIUS, + borderTopLeftRadius: spec.inactiveTrailingRadius, + borderTopRightRadius: spec.inactiveTrailingRadius, + } + : { + borderTopLeftRadius: INNER_CORNER_RADIUS, + borderBottomLeftRadius: INNER_CORNER_RADIUS, + borderTopRightRadius: spec.inactiveTrailingRadius, + borderBottomRightRadius: spec.inactiveTrailingRadius, + }; + + // Active track: leading outer corner uses size spec (standard only), all others 2dp inner + const activeTrackCorners: ViewStyle = isVertical + ? { + borderBottomLeftRadius: + variant === 'standard' + ? spec.activeLeadingRadius + : INNER_CORNER_RADIUS, + borderBottomRightRadius: + variant === 'standard' + ? spec.activeLeadingRadius + : INNER_CORNER_RADIUS, + borderTopLeftRadius: INNER_CORNER_RADIUS, + borderTopRightRadius: INNER_CORNER_RADIUS, + } + : { + borderTopLeftRadius: + variant === 'standard' + ? spec.activeLeadingRadius + : INNER_CORNER_RADIUS, + borderBottomLeftRadius: + variant === 'standard' + ? spec.activeLeadingRadius + : INNER_CORNER_RADIUS, + borderTopRightRadius: INNER_CORNER_RADIUS, + borderBottomRightRadius: INNER_CORNER_RADIUS, + }; + + // Outer wrapper: extends for indicator space above the track (horizontal only) + const outerStyle: ViewStyle = isVertical + ? { width: spec.handleHeight, overflow: 'visible' } + : { height: spec.handleHeight + verticalOffset, overflow: 'visible' }; + + // Static handle positioning perpendicular to the track axis + const endHandleStaticStyle: ViewStyle = isVertical + ? { position: 'absolute', left: 0, right: 0 } + : { position: 'absolute', top: verticalOffset, bottom: 0 }; + + const startHandleStaticStyle: ViewStyle = isVertical + ? { position: 'absolute', left: 0, right: 0 } + : { position: 'absolute', top: verticalOffset, bottom: 0 }; + + // Icon: center it at the leading corner's inscribed-square focal point + const iconLeadingOffset = Math.max( + 0, + spec.activeLeadingRadius - spec.iconSize / 2 + ); + + const inactiveColor = disabled + ? colors.disabledContent + : colors.inactiveTrack; + const activeColor = disabled ? colors.disabledContent : colors.activeTrack; + + return ( + + {/* Gesture and accessibility wrapper */} + { + if (evt.nativeEvent.actionName === 'increment') { + handleAccessibilityIncrement(); + } else if (evt.nativeEvent.actionName === 'decrement') { + handleAccessibilityDecrement(); + } + }} + accessibilityLabel={accessibilityLabel} + accessibilityState={{ disabled }} + /> + + {/* Track container */} + + {/* Left inactive track segment (centered/range only) */} + {variant !== 'standard' && ( + + )} + + {/* Right inactive track segment */} + + + {/* Active track segment */} + + + {/* Stop indicators: always-on end stops + optional intermediate step ticks */} + {allStopFractions.map((f) => { + const isActive = f >= activeLead && f <= activeTrail; + // Stops are centered at the corner arc center: + // f=0 -> spec.activeLeadingRadius from leading edge + // f=1 -> spec.inactiveTrailingRadius from trailing edge + // intermediate -> lerp between the two + const innerStart = spec.activeLeadingRadius; + const innerEnd = trackLength - spec.inactiveTrailingRadius; + if (innerEnd <= innerStart) return null; + const pixelCenter = innerStart + f * (innerEnd - innerStart); + const dotStyle: ViewStyle = isVertical + ? { + position: 'absolute', + left: (spec.trackThickness - STOP_SIZE) / 2, + bottom: pixelCenter - STOP_SIZE / 2, + } + : { + position: 'absolute', + top: (spec.trackThickness - STOP_SIZE) / 2, + left: pixelCenter - STOP_SIZE / 2, + }; + return ( + + ); + })} + + {/* Inset icon: positioned at the leading corner's focal point */} + {spec.iconSize > 0 && icon != null && !disabled && ( + + + + )} + + + {/* End handle */} + + + {/* Start handle (range only) */} + {isRange && ( + + )} + + {/* Value indicator bubble */} + {showValueIndicator && ( + + + {indicatorText} + + + )} + + ); +}; + +const styles = StyleSheet.create({ + trackSegment: { + position: 'absolute', + }, + handle: { + borderRadius: cornerFull, + }, + stopDot: { + width: STOP_SIZE, + height: STOP_SIZE, + borderRadius: STOP_SIZE / 2, + }, + iconWrapper: { + position: 'absolute', + }, + valueIndicator: { + position: 'absolute', + top: 0, + width: VALUE_INDICATOR_SIZE, + height: VALUE_INDICATOR_SIZE, + borderRadius: cornerFull, + justifyContent: 'center', + alignItems: 'center', + }, +}); + +export default Slider; diff --git a/src/components/Slider/index.ts b/src/components/Slider/index.ts new file mode 100644 index 0000000000..d55e4dd87e --- /dev/null +++ b/src/components/Slider/index.ts @@ -0,0 +1,22 @@ +import * as React from 'react'; + +import SliderComponent, { type Props } from './Slider'; + +type CenteredProps = Omit, 'variant'>; +type RangeProps = Omit, 'variant'>; + +const Slider = Object.assign( + // @component ./Slider.tsx + SliderComponent, + { + // @component ./Slider.tsx (variant="centered") + Centered: (props: CenteredProps) => + React.createElement(SliderComponent, { ...props, variant: 'centered' }), + // @component ./Slider.tsx (variant="range") + Range: (props: RangeProps) => + React.createElement(SliderComponent, { ...props, variant: 'range' }), + } +); + +export default Slider; +export type { Props as SliderProps } from './Slider'; diff --git a/src/components/Slider/tokens.ts b/src/components/Slider/tokens.ts new file mode 100644 index 0000000000..f101ff7633 --- /dev/null +++ b/src/components/Slider/tokens.ts @@ -0,0 +1,89 @@ +import type { ColorRole } from '../../theme/types'; + +export type SliderSize = 'xs' | 's' | 'm' | 'l' | 'xl'; + +type SizeSpec = { + trackThickness: number; + handleHeight: number; + handleWidth: number; + handlePressWidth: number; + activeLeadingRadius: number; + inactiveTrailingRadius: number; + iconSize: number; + iconPadding: number; +}; + +export const SIZE_SPECS: Record = { + xs: { + trackThickness: 16, + handleHeight: 44, + handleWidth: 4, + handlePressWidth: 2, + activeLeadingRadius: 8, + inactiveTrailingRadius: 8, + iconSize: 0, + iconPadding: 0, + }, + s: { + trackThickness: 24, + handleHeight: 44, + handleWidth: 4, + handlePressWidth: 2, + activeLeadingRadius: 8, + inactiveTrailingRadius: 8, + iconSize: 0, + iconPadding: 0, + }, + m: { + trackThickness: 40, + handleHeight: 44, + handleWidth: 4, + handlePressWidth: 2, + activeLeadingRadius: 12, + inactiveTrailingRadius: 6, + iconSize: 24, + iconPadding: 6, + }, + l: { + trackThickness: 56, + handleHeight: 68, + handleWidth: 4, + handlePressWidth: 2, + activeLeadingRadius: 16, + inactiveTrailingRadius: 16, + iconSize: 24, + iconPadding: 6, + }, + xl: { + trackThickness: 96, + handleHeight: 108, + handleWidth: 4, + handlePressWidth: 2, + activeLeadingRadius: 28, + inactiveTrailingRadius: 28, + iconSize: 32, + iconPadding: 8, + }, +} as const; + +export const STOP_SIZE = 4; +export const BETWEEN_HANDLE_SPACE = 4; +export const INNER_CORNER_RADIUS = 2; +export const VALUE_INDICATOR_SIZE = 44; +export const VALUE_INDICATOR_BOTTOM_SPACE = 12; + +export const DISABLED_CONTENT_OPACITY = 0.38; +export const DISABLED_INACTIVE_OPACITY = 0.12; + +const colors = { + activeTrack: 'primary', + inactiveTrack: 'secondaryContainer', + handle: 'primary', + stopOnActive: 'onPrimary', + stopOnInactive: 'onSecondaryContainer', + valueIndicatorBg: 'inverseSurface', + valueIndicatorText: 'inverseOnSurface', + disabledContent: 'onSurface', +} as const satisfies Record; + +export const SliderTokens = { colors }; diff --git a/src/components/Slider/utils.ts b/src/components/Slider/utils.ts new file mode 100644 index 0000000000..41769af863 --- /dev/null +++ b/src/components/Slider/utils.ts @@ -0,0 +1,92 @@ +export function clamp(value: number, min: number, max: number): number { + return Math.min(Math.max(value, min), max); +} + +export function snapToStep( + value: number, + min: number, + max: number, + step: number +): number { + if (step <= 0) return clamp(value, min, max); + const snapped = Math.round((value - min) / step) * step + min; + return clamp(snapped, min, max); +} + +export function valueToFraction( + value: number, + min: number, + max: number +): number { + if (max === min) return 0; + return clamp((value - min) / (max - min), 0, 1); +} + +export function fractionToValue( + fraction: number, + min: number, + max: number, + step: number +): number { + const raw = min + clamp(fraction, 0, 1) * (max - min); + return snapToStep(raw, min, max, step); +} + +export function positionToFraction( + touchPx: number, + trackLengthPx: number, + isRTL: boolean, + isVertical: boolean +): number { + if (trackLengthPx <= 0) return 0; + let fraction = touchPx / trackLengthPx; + // Vertical: top of track = max, bottom = min (invert) + if (isVertical) fraction = 1 - fraction; + // RTL horizontal: invert + if (!isVertical && isRTL) fraction = 1 - fraction; + return clamp(fraction, 0, 1); +} + +export function stopFractions( + min: number, + max: number, + step: number +): number[] { + if (step <= 0 || max <= min) return []; + const fractions: number[] = []; + let v = min; + while (v <= max + Number.EPSILON) { + fractions.push(valueToFraction(v, min, max)); + v += step; + } + return fractions; +} + +export type SliderVariant = 'standard' | 'centered' | 'range'; + +export function activeSegment( + variant: SliderVariant, + valueFraction: number, + startFraction: number +): [number, number] { + if (variant === 'range') { + return [ + Math.min(startFraction, valueFraction), + Math.max(startFraction, valueFraction), + ]; + } + if (variant === 'centered') { + return [Math.min(0.5, valueFraction), Math.max(0.5, valueFraction)]; + } + return [0, valueFraction]; +} + +export function nearestHandle( + touchFraction: number, + startFraction: number, + endFraction: number +): 'start' | 'end' { + const distStart = Math.abs(touchFraction - startFraction); + const distEnd = Math.abs(touchFraction - endFraction); + return distStart < distEnd ? 'start' : 'end'; +} diff --git a/src/components/__tests__/Slider.test.tsx b/src/components/__tests__/Slider.test.tsx new file mode 100644 index 0000000000..0abed3a919 --- /dev/null +++ b/src/components/__tests__/Slider.test.tsx @@ -0,0 +1,205 @@ +import * as React from 'react'; + +import { render } from '../../test-utils'; +import Slider from '../Slider'; +import { + activeSegment, + fractionToValue, + nearestHandle, + positionToFraction, + snapToStep, + stopFractions, + valueToFraction, +} from '../Slider/utils'; + +// ---- Utility unit tests ---- + +describe('snapToStep', () => { + it('returns value clamped to [min, max] when step is 0', () => { + expect(snapToStep(150, 0, 100, 0)).toBe(100); + expect(snapToStep(-10, 0, 100, 0)).toBe(0); + expect(snapToStep(42, 0, 100, 0)).toBe(42); + }); + + it('snaps to nearest step', () => { + expect(snapToStep(23, 0, 100, 25)).toBe(25); + expect(snapToStep(12, 0, 100, 25)).toBe(0); + expect(snapToStep(38, 0, 100, 25)).toBe(50); + expect(snapToStep(63, 0, 100, 25)).toBe(75); + }); + + it('clamps snapped value to bounds', () => { + expect(snapToStep(99, 0, 100, 25)).toBe(100); + expect(snapToStep(1, 0, 100, 25)).toBe(0); + }); +}); + +describe('valueToFraction', () => { + it('maps min to 0 and max to 1', () => { + expect(valueToFraction(0, 0, 100)).toBe(0); + expect(valueToFraction(100, 0, 100)).toBe(1); + }); + + it('maps midpoint to 0.5', () => { + expect(valueToFraction(50, 0, 100)).toBe(0.5); + }); + + it('returns 0 when min === max', () => { + expect(valueToFraction(50, 50, 50)).toBe(0); + }); +}); + +describe('fractionToValue', () => { + it('maps 0 to min and 1 to max', () => { + expect(fractionToValue(0, 0, 100, 0)).toBe(0); + expect(fractionToValue(1, 0, 100, 0)).toBe(100); + }); + + it('clamps out-of-range fractions', () => { + expect(fractionToValue(-0.5, 0, 100, 0)).toBe(0); + expect(fractionToValue(1.5, 0, 100, 0)).toBe(100); + }); +}); + +describe('positionToFraction', () => { + it('maps 0 to 0 and trackLength to 1 in LTR horizontal', () => { + expect(positionToFraction(0, 100, false, false)).toBe(0); + expect(positionToFraction(100, 100, false, false)).toBe(1); + expect(positionToFraction(50, 100, false, false)).toBe(0.5); + }); + + it('inverts for RTL', () => { + expect(positionToFraction(0, 100, true, false)).toBe(1); + expect(positionToFraction(100, 100, true, false)).toBe(0); + }); + + it('inverts for vertical (top=high, bottom=low)', () => { + expect(positionToFraction(0, 100, false, true)).toBe(1); + expect(positionToFraction(100, 100, false, true)).toBe(0); + expect(positionToFraction(25, 100, false, true)).toBe(0.75); + }); + + it('returns 0 when trackLengthPx is 0', () => { + expect(positionToFraction(50, 0, false, false)).toBe(0); + }); +}); + +describe('stopFractions', () => { + it('returns empty array when step is 0', () => { + expect(stopFractions(0, 100, 0)).toEqual([]); + }); + + it('returns correct fractions for step=25 on [0,100]', () => { + const fracs = stopFractions(0, 100, 25); + expect(fracs).toHaveLength(5); + expect(fracs[0]).toBeCloseTo(0); + expect(fracs[1]).toBeCloseTo(0.25); + expect(fracs[2]).toBeCloseTo(0.5); + expect(fracs[3]).toBeCloseTo(0.75); + expect(fracs[4]).toBeCloseTo(1); + }); + + it('returns empty array when max <= min', () => { + expect(stopFractions(100, 100, 25)).toEqual([]); + }); +}); + +describe('activeSegment', () => { + it('standard: returns [0, valueFraction]', () => { + expect(activeSegment('standard', 0.6, 0)).toEqual([0, 0.6]); + expect(activeSegment('standard', 0, 0)).toEqual([0, 0]); + }); + + it('centered: returns segment between 0.5 and valueFraction', () => { + expect(activeSegment('centered', 0.7, 0)).toEqual([0.5, 0.7]); + expect(activeSegment('centered', 0.3, 0)).toEqual([0.3, 0.5]); + expect(activeSegment('centered', 0.5, 0)).toEqual([0.5, 0.5]); + }); + + it('range: returns ordered [start, end]', () => { + expect(activeSegment('range', 0.8, 0.2)).toEqual([0.2, 0.8]); + expect(activeSegment('range', 0.2, 0.8)).toEqual([0.2, 0.8]); + }); +}); + +describe('nearestHandle', () => { + it('returns start when closer to start', () => { + expect(nearestHandle(0.2, 0.1, 0.9)).toBe('start'); + }); + + it('returns end when closer to end', () => { + expect(nearestHandle(0.8, 0.1, 0.9)).toBe('end'); + }); + + it('tie-breaks to end', () => { + expect(nearestHandle(0.5, 0.25, 0.75)).toBe('end'); + }); +}); + +// ---- Component render tests ---- + +describe('Slider renders', () => { + it('standard slider', () => { + const tree = render().toJSON(); + expect(tree).toMatchSnapshot(); + }); + + it('each size', () => { + for (const size of ['xs', 's', 'm', 'l', 'xl'] as const) { + const tree = render().toJSON(); + expect(tree).toMatchSnapshot(); + } + }); + + it('centered variant', () => { + const tree = render().toJSON(); + expect(tree).toMatchSnapshot(); + }); + + it('range variant', () => { + const tree = render().toJSON(); + expect(tree).toMatchSnapshot(); + }); + + it('disabled standard', () => { + const tree = render().toJSON(); + expect(tree).toMatchSnapshot(); + }); + + it('with stop indicators', () => { + const tree = render().toJSON(); + expect(tree).toMatchSnapshot(); + }); + + it('with value indicator', () => { + const tree = render().toJSON(); + expect(tree).toMatchSnapshot(); + }); + + it('vertical orientation', () => { + const tree = render().toJSON(); + expect(tree).toMatchSnapshot(); + }); + + it('Slider.Centered shorthand', () => { + const tree = render().toJSON(); + expect(tree).toMatchSnapshot(); + }); + + it('Slider.Range shorthand', () => { + const tree = render().toJSON(); + expect(tree).toMatchSnapshot(); + }); +}); + +describe('Slider accessibility', () => { + it('has adjustable role with correct value', () => { + const { getByRole } = render(); + const slider = getByRole('adjustable'); + expect(slider.props.accessibilityValue).toEqual({ + min: 0, + max: 100, + now: 42, + }); + }); +}); diff --git a/src/components/__tests__/__snapshots__/Slider.test.tsx.snap b/src/components/__tests__/__snapshots__/Slider.test.tsx.snap new file mode 100644 index 0000000000..e4fd18a3e7 --- /dev/null +++ b/src/components/__tests__/__snapshots__/Slider.test.tsx.snap @@ -0,0 +1,2380 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Slider renders Slider.Centered shorthand 1`] = ` + + + + + + + + + +`; + +exports[`Slider renders Slider.Range shorthand 1`] = ` + + + + + + + + + + +`; + +exports[`Slider renders centered variant 1`] = ` + + + + + + + + + +`; + +exports[`Slider renders disabled standard 1`] = ` + + + + + + + + +`; + +exports[`Slider renders each size 1`] = ` + + + + + + + + +`; + +exports[`Slider renders each size 2`] = ` + + + + + + + + +`; + +exports[`Slider renders each size 3`] = ` + + + + + + + + +`; + +exports[`Slider renders each size 4`] = ` + + + + + + + + +`; + +exports[`Slider renders each size 5`] = ` + + + + + + + + +`; + +exports[`Slider renders range variant 1`] = ` + + + + + + + + + + +`; + +exports[`Slider renders standard slider 1`] = ` + + + + + + + + +`; + +exports[`Slider renders vertical orientation 1`] = ` + + + + + + + + +`; + +exports[`Slider renders with stop indicators 1`] = ` + + + + + + + + +`; + +exports[`Slider renders with value indicator 1`] = ` + + + + + + + + + + 50 + + + +`; diff --git a/src/index.tsx b/src/index.tsx index 8863e2fa20..7c85e3a89b 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -49,6 +49,7 @@ export { default as TouchableRipple } from './components/TouchableRipple/Touchab export { default as TextInput } from './components/TextInput'; export { default as ToggleButton } from './components/ToggleButton'; export { default as SegmentedButtons } from './components/SegmentedButtons/SegmentedButtons'; +export { default as Slider } from './components/Slider'; export { default as Tooltip } from './components/Tooltip/Tooltip'; export { default as Text, customText } from './components/Typography/Text'; @@ -125,6 +126,7 @@ export type { Props as RadioButtonGroupProps } from './components/RadioButton/Ra export type { Props as RadioButtonIOSProps } from './components/RadioButton/RadioButtonIOS'; export type { Props as RadioButtonItemProps } from './components/RadioButton/RadioButtonItem'; export type { Props as SearchbarProps } from './components/Searchbar'; +export type { Props as SliderProps } from './components/Slider/Slider'; export type { Props as SnackbarProps } from './components/Snackbar'; export type { Props as SurfaceProps } from './components/Surface'; export type { Props as SwitchProps } from './components/Switch/Switch';