From 9c70c947b18c60fef7614f168a460ae8d0c354f0 Mon Sep 17 00:00:00 2001 From: Priyanshu0007 Date: Fri, 25 Sep 2026 00:35:22 +0530 Subject: [PATCH] feat: add Material 3 Slider and RangeSlider components --- docs/component-docs.config.ts | 4 + example/src/ExampleList.tsx | 2 + example/src/Examples/SliderExample.tsx | 338 +++++ src/components/Slider/RangeSlider.tsx | 1183 +++++++++++++++++ src/components/Slider/Slider.tsx | 996 ++++++++++++++ src/components/Slider/index.ts | 17 + src/components/Slider/tokens.ts | 72 + src/components/Slider/utils.ts | 137 ++ src/components/__tests__/Slider.test.tsx | 364 +++++ .../__snapshots__/Slider.test.tsx.snap | 1183 +++++++++++++++++ src/index.tsx | 2 + 11 files changed, 4298 insertions(+) create mode 100644 example/src/Examples/SliderExample.tsx create mode 100644 src/components/Slider/RangeSlider.tsx create mode 100644 src/components/Slider/Slider.tsx create mode 100644 src/components/Slider/index.ts create mode 100644 src/components/Slider/tokens.ts create mode 100644 src/components/Slider/utils.ts create mode 100644 src/components/__tests__/Slider.test.tsx create mode 100644 src/components/__tests__/__snapshots__/Slider.test.tsx.snap diff --git a/docs/component-docs.config.ts b/docs/component-docs.config.ts index 2cf106849c..aab298990b 100644 --- a/docs/component-docs.config.ts +++ b/docs/component-docs.config.ts @@ -139,6 +139,10 @@ const pages = { Switch: { Switch: 'Switch/Switch', }, + Slider: { + Slider: 'Slider/Slider', + RangeSlider: 'Slider/RangeSlider', + }, TextInput: { TextInput: { source: 'TextInput/TextInput', diff --git a/example/src/ExampleList.tsx b/example/src/ExampleList.tsx index 2af1eed385..a878a5cd86 100644 --- a/example/src/ExampleList.tsx +++ b/example/src/ExampleList.tsx @@ -35,6 +35,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'; @@ -78,6 +79,7 @@ export const mainExamples = { Searchbar: SearchbarExample, SegmentedButton: SegmentedButtonExample, Snackbar: SnackbarExample, + Slider: SliderExample, Surface: SurfaceExample, Switch: SwitchExample, Text: TextExample, diff --git a/example/src/Examples/SliderExample.tsx b/example/src/Examples/SliderExample.tsx new file mode 100644 index 0000000000..7a91694c16 --- /dev/null +++ b/example/src/Examples/SliderExample.tsx @@ -0,0 +1,338 @@ +import * as React from 'react'; +import { StyleSheet, View } from 'react-native'; + +import { + Card, + Divider, + RangeSlider, + Slider, + Surface, + Switch, + Text, + useTheme, +} from 'react-native-paper'; + +import ScreenWrapper from '../ScreenWrapper'; + +const SliderExample = () => { + const theme = useTheme(); + + // State values for various slider configurations + const [continuousVal, setContinuousVal] = React.useState(45); + const [discreteVal, setDiscreteVal] = React.useState(30); + const [centeredVal, setCenteredVal] = React.useState(15); + const [rangeVals, setRangeVals] = React.useState<[number, number]>([25, 75]); + const [volumeVal, setVolumeVal] = React.useState(60); + const [brightnessVal, setBrightnessVal] = React.useState(80); + const [temperatureVal, setTemperatureVal] = React.useState(22); + const [alwaysVisibleVal, setAlwaysVisibleVal] = React.useState(24); + const [tertiaryVal, setTertiaryVal] = React.useState(70); + const [disableAll, setDisableAll] = React.useState(false); + + // Custom tertiary theme for demonstrating theming capabilities + const tertiaryTheme = React.useMemo( + () => ({ + colors: { + primary: theme.colors.tertiary, + onPrimary: theme.colors.onTertiary, + primaryContainer: theme.colors.tertiaryContainer, + secondary: theme.colors.tertiary, + surfaceContainerHighest: theme.colors.surfaceVariant, + }, + }), + [theme] + ); + + return ( + + {/* Intro Header */} + + + Material Design 3 Sliders + + + Sliders let users make selections from a range of values. Conforms to + M3 specification with 16dp track, 4dp handle, 6dp gap, and stop + indicators. + + + + {/* Global Disable Toggle */} + + Disable all sliders + + + + + + {/* 1. Continuous Slider */} + + + + + Allows selection of a continuous value along a smooth range. + + `${Math.round(v)}%`} + /> + + + + {/* 2. Value Indicator Formats & Behaviors */} + + + + + Always visible label with temperature formatting (°C): + + `${v}°C`} + disabled={disableAll} + /> + + + + + {'Hidden value indicator (labelBehavior="gone"): '} + + + + + + {/* 3. Discrete Slider with Steps & Tick Marks */} + + + + + Restricts selection to predetermined steps with M3 stop indicators. + + `${v}`} + /> + + + + {/* 3. Centered Slider */} + + 0 ? `+${centeredVal}` : centeredVal}`} + /> + + + Active track originates from the center (origin: 0, range: -50 to + +50). + + (v > 0 ? `+${v} dB` : `${v} dB`)} + /> + + + + {/* 4. Range Slider */} + + + + + Selects a minimum and maximum range between two independent handles. + + `$${v}`} + /> + + + + {/* 5. With Icons */} + + + + + Volume control: + + `${Math.round(v)}%`} + /> + + + + + Display brightness: + + `${Math.round(v)}%`} + /> + + + + {/* 7. Theming & Custom Colors */} + + + + + Custom theme with tertiary color roles and step ticks: + + `${v}`} + /> + + + + {/* 8. Disabled State Showcase */} + + + + + Disabled Continuous: + + + + + + + Disabled Discrete with Ticks: + + + + + + + Disabled Range: + + + + + + ); +}; + +SliderExample.title = 'Slider'; + +const styles = StyleSheet.create({ + container: { + padding: 16, + paddingBottom: 40, + }, + headerSurface: { + padding: 16, + borderRadius: 12, + marginBottom: 16, + }, + title: { + fontWeight: '700', + marginBottom: 6, + }, + subtitle: { + opacity: 0.8, + lineHeight: 20, + }, + toggleRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingVertical: 8, + paddingHorizontal: 4, + }, + divider: { + marginVertical: 12, + }, + card: { + marginBottom: 16, + }, + description: { + marginBottom: 12, + opacity: 0.75, + }, + spacing: { + height: 16, + }, + visibleSlider: { + marginTop: 48, + marginBottom: 8, + }, +}); + +export default SliderExample; diff --git a/src/components/Slider/RangeSlider.tsx b/src/components/Slider/RangeSlider.tsx new file mode 100644 index 0000000000..d63a6e4761 --- /dev/null +++ b/src/components/Slider/RangeSlider.tsx @@ -0,0 +1,1183 @@ +import * as React from 'react'; +import { + type GestureResponderEvent, + type LayoutChangeEvent, + Platform, + StyleSheet, + type StyleProp, + View, + type ViewStyle, +} from 'react-native'; + +import Animated, { + useAnimatedStyle, + useSharedValue, + withTiming, +} from 'react-native-reanimated'; + +import { SliderTokens } from './tokens'; +import { + clamp, + getDefaultSliderColors, + getRatioFromValue, + getTickMarks, + getValueFromRatio, + snapToStep, +} 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 '../../theme/types'; +import Icon, { type IconSource } from '../Icon'; +import Text from '../Typography/Text'; + +export type Props = { + /** + * Current range values of the slider [start, end] (controlled). + */ + values?: [number, number]; + /** + * Initial values when uncontrolled. Defaults to `[min, max]`. + */ + defaultValues?: [number, number]; + /** + * Minimum value of the range slider. Defaults to 0. + */ + min?: number; + /** + * Maximum value of the range slider. Defaults to 100. + */ + max?: number; + /** + * Step increment for discrete sliders. + */ + step?: number; + /** + * Minimum separation distance between the two thumbs. + */ + minSeparation?: number; + /** + * Whether to render stop indicators (tick marks) along the track. + * Defaults to `true` if `step` is provided, `false` otherwise. + */ + showTickMarks?: boolean; + /** + * Behavior of the value indicator tooltips. + * - `'floating'`: appears when interacting (default) + * - `'visible'`: always visible + * - `'gone'`: never shown + */ + labelBehavior?: 'floating' | 'visible' | 'gone'; + /** + * Function to format the text inside the value indicators. + */ + formatValueIndicator?: (value: number) => string; + /** + * Icon displayed before the slider track. + */ + startIcon?: IconSource; + /** + * Icon displayed after the slider track. + */ + endIcon?: IconSource; + /** + * Callback called when the values change during dragging. + */ + onValueChange?: (values: [number, number]) => void; + /** + * Callback called when interaction begins. + */ + onSlidingStart?: (values: [number, number]) => void; + /** + * Callback called when interaction ends. + */ + onSlidingComplete?: (values: [number, number]) => void; + /** + * Disables interaction and renders the disabled visual state. + */ + disabled?: boolean; + /** + * Custom height for the track (defaults to 16dp per M3 specs). + */ + trackHeight?: number; + /** + * Custom gap size between thumb and track (defaults to 6dp per M3 specs). + */ + thumbTrackGapSize?: number; + /** + * Custom color for the active track segment. + */ + activeColor?: string; + /** + * Custom color for the inactive track segments. + */ + inactiveColor?: string; + /** + * Custom color for the thumb handles. + */ + thumbColor?: string; + style?: StyleProp; + testID?: string; + theme?: ThemeProp; + /** + * Accessibility label for the start thumb. + */ + startAccessibilityLabel?: string; + /** + * Accessibility label for the end thumb. + */ + endAccessibilityLabel?: string; +}; + +const { + trackHeight: DEFAULT_TRACK_HEIGHT, + trackCornerRadius: DEFAULT_TRACK_CORNER_RADIUS, + trackInsideCornerRadius: TRACK_INSIDE_CORNER_RADIUS, + thumbWidth: DEFAULT_THUMB_WIDTH, + thumbHeight: DEFAULT_THUMB_HEIGHT, + thumbCornerRadius: THUMB_CORNER_RADIUS, + thumbTrackGapSize: DEFAULT_GAP_SIZE, + stopIndicatorSize: STOP_INDICATOR_SIZE, + minTouchTargetSize: MIN_TOUCH_TARGET_SIZE, + stateLayerSize: STATE_LAYER_SIZE, + valueIndicatorHeight: VALUE_INDICATOR_HEIGHT, + valueIndicatorMinWidth: VALUE_INDICATOR_MIN_WIDTH, + valueIndicatorPaddingHorizontal: VALUE_INDICATOR_PADDING_HORIZONTAL, + valueIndicatorCornerRadius: VALUE_INDICATOR_CORNER_RADIUS, + valueIndicatorGap: VALUE_INDICATOR_GAP, + iconSize: DEFAULT_ICON_SIZE, + iconGap: DEFAULT_ICON_GAP, + disabledActiveTrackOpacity: DISABLED_ACTIVE_TRACK_OPACITY, + disabledInactiveTrackOpacity: DISABLED_INACTIVE_TRACK_OPACITY, + disabledHandleOpacity: DISABLED_HANDLE_OPACITY, + disabledStopIndicatorOpacity: DISABLED_STOP_INDICATOR_OPACITY, + disabledIconOpacity: DISABLED_ICON_OPACITY, +} = SliderTokens; + +/** + * Material Design 3 Range Slider component. + * + * Allows users to select a range between two values with dual handles. + * + * ## Usage + * ```js + * import * as React from 'react'; + * import { RangeSlider } from 'react-native-paper'; + * + * const Example = () => { + * const [range, setRange] = React.useState([20, 80]); + * return ; + * }; + * ``` + */ +const RangeSlider = ({ + values: controlledValues, + defaultValues, + min = 0, + max = 100, + step, + minSeparation = 0, + showTickMarks = step !== undefined, + labelBehavior = 'floating', + formatValueIndicator, + startIcon, + endIcon, + onValueChange, + onSlidingStart, + onSlidingComplete, + disabled = false, + trackHeight = DEFAULT_TRACK_HEIGHT, + thumbTrackGapSize = DEFAULT_GAP_SIZE, + activeColor, + inactiveColor, + thumbColor, + style, + testID, + theme: themeOverrides, + startAccessibilityLabel = 'Minimum', + endAccessibilityLabel = 'Maximum', +}: Props) => { + const theme = useInternalTheme(themeOverrides); + const reduceMotion = useReduceMotion(); + const { direction } = useLocale(); + const isRTL = direction === 'rtl'; + + const defaultSliderColors = React.useMemo( + () => getDefaultSliderColors(theme), + [theme] + ); + + const isControlled = controlledValues !== undefined; + const initialRange = defaultValues ?? [min, max]; + const [internalValues, setInternalValues] = React.useState<[number, number]>([ + snapToStep(initialRange[0], min, max, step), + snapToStep(initialRange[1], min, max, step), + ]); + + const currentValues: [number, number] = React.useMemo( + () => + isControlled + ? [ + snapToStep(controlledValues[0], min, max, step), + snapToStep(controlledValues[1], min, max, step), + ] + : internalValues, + [isControlled, controlledValues, min, max, step, internalValues] + ); + + const [trackWidth, setTrackWidth] = React.useState(0); + const trackRef = React.useRef(null); + const trackPageX = React.useRef(0); + + const activeThumbRef = React.useRef<'start' | 'end' | null>(null); + const isInteractingStartSV = useSharedValue(0); + const isInteractingEndSV = useSharedValue(0); + const [isStartFocused, setIsStartFocused] = React.useState(false); + const [isEndFocused, setIsEndFocused] = React.useState(false); + const [startIndicatorWidth, setStartIndicatorWidth] = React.useState( + VALUE_INDICATOR_MIN_WIDTH + ); + const [endIndicatorWidth, setEndIndicatorWidth] = React.useState( + VALUE_INDICATOR_MIN_WIDTH + ); + + const handleStartIndicatorLayout = React.useCallback( + (e: LayoutChangeEvent) => { + const w = e.nativeEvent.layout.width; + if (w > 0 && Math.abs(w - startIndicatorWidth) > 0.5) { + setStartIndicatorWidth(w); + } + }, + [startIndicatorWidth] + ); + + const handleEndIndicatorLayout = React.useCallback( + (e: LayoutChangeEvent) => { + const w = e.nativeEvent.layout.width; + if (w > 0 && Math.abs(w - endIndicatorWidth) > 0.5) { + setEndIndicatorWidth(w); + } + }, + [endIndicatorWidth] + ); + + const latestCallbacks = React.useRef({ + onValueChange, + onSlidingStart, + onSlidingComplete, + currentValues, + min, + max, + step, + minSeparation, + disabled, + isRTL, + isControlled, + }); + + React.useEffect(() => { + latestCallbacks.current = { + onValueChange, + onSlidingStart, + onSlidingComplete, + currentValues, + min, + max, + step, + minSeparation, + disabled, + isRTL, + isControlled, + }; + }); + + const updateRangeFromX = React.useCallback( + (x: number) => { + const { + min: curMin, + max: curMax, + step: curStep, + minSeparation: curSeparation, + isRTL: curRTL, + onValueChange: cbValueChange, + currentValues: curVals, + isControlled: curControlled, + } = latestCallbacks.current; + + const radius = trackHeight / 2 || DEFAULT_TRACK_CORNER_RADIUS; + const travelWidth = Math.max(0, trackWidth - 2 * radius); + if (travelWidth <= 0 || !activeThumbRef.current) return; + + let ratio = clamp((x - radius) / travelWidth, 0, 1); + if (curRTL) { + ratio = 1 - ratio; + } + + const rawVal = getValueFromRatio(ratio, curMin, curMax, curStep); + + let nextVals: [number, number]; + if (activeThumbRef.current === 'start') { + const clampedStart = clamp(rawVal, curMin, curVals[1] - curSeparation); + nextVals = [clampedStart, curVals[1]]; + } else { + const clampedEnd = clamp(rawVal, curVals[0] + curSeparation, curMax); + nextVals = [curVals[0], clampedEnd]; + } + + if (nextVals[0] !== curVals[0] || nextVals[1] !== curVals[1]) { + if (!curControlled) { + setInternalValues(nextVals); + } + cbValueChange?.(nextVals); + } + }, + [trackHeight, trackWidth] + ); + + const handleStartShouldSetResponder = React.useCallback( + () => !disabled, + [disabled] + ); + + const handleMoveShouldSetResponder = React.useCallback( + () => !disabled, + [disabled] + ); + + const handleTerminationRequest = React.useCallback(() => false, []); + + const handleResponderGrant = React.useCallback( + (evt: GestureResponderEvent) => { + if (disabled) return false; + const { + currentValues: curVals, + min: curMin, + max: curMax, + isRTL: curRTL, + } = latestCallbacks.current; + + const pageX = evt.nativeEvent.pageX; + const locationX = evt.nativeEvent.locationX; + trackPageX.current = pageX - locationX; + + const radius = trackHeight / 2 || DEFAULT_TRACK_CORNER_RADIUS; + const travelWidth = Math.max(0, trackWidth - 2 * radius); + + const startR = getRatioFromValue(curVals[0], curMin, curMax); + const endR = getRatioFromValue(curVals[1], curMin, curMax); + + const startVisualR = curRTL ? 1 - startR : startR; + const endVisualR = curRTL ? 1 - endR : endR; + + const startX = radius + startVisualR * travelWidth; + const endX = radius + endVisualR * travelWidth; + + const distToStart = Math.abs(locationX - startX); + const distToEnd = Math.abs(locationX - endX); + + const thumb = distToStart <= distToEnd ? 'start' : 'end'; + activeThumbRef.current = thumb; + + if (thumb === 'start') { + isInteractingStartSV.value = 1; + } else { + isInteractingEndSV.value = 1; + } + + latestCallbacks.current.onSlidingStart?.(curVals); + updateRangeFromX(locationX); + + if (trackRef.current) { + trackRef.current.measure((_x, _y, _width, _height, measuredPageX) => { + if (!isNaN(measuredPageX)) { + trackPageX.current = measuredPageX; + } + }); + } + + return true; + }, + [ + disabled, + trackHeight, + trackWidth, + isInteractingStartSV, + isInteractingEndSV, + updateRangeFromX, + ] + ); + + const handleResponderMove = React.useCallback( + (evt: GestureResponderEvent) => { + if (disabled) return; + const localX = evt.nativeEvent.pageX - trackPageX.current; + updateRangeFromX(localX); + }, + [disabled, updateRangeFromX] + ); + + const handleResponderEnd = React.useCallback(() => { + isInteractingStartSV.value = 0; + isInteractingEndSV.value = 0; + activeThumbRef.current = null; + latestCallbacks.current.onSlidingComplete?.( + latestCallbacks.current.currentValues + ); + }, [isInteractingStartSV, isInteractingEndSV]); + + const onTrackLayout = React.useCallback((e: LayoutChangeEvent) => { + const { width } = e.nativeEvent.layout; + setTrackWidth(width); + if (trackRef.current) { + trackRef.current.measure((_x, _y, _width, _height, pageX) => { + if (!isNaN(pageX)) { + trackPageX.current = pageX; + } + }); + } + }, []); + + const handleStartAccessibilityAction = React.useCallback( + (event: { nativeEvent: { actionName: string } }) => { + if (disabled) return; + const stepVal = step ?? (max - min) / 100; + let next = currentValues[0]; + if (event.nativeEvent.actionName === 'increment') { + next = currentValues[0] + stepVal; + } else if (event.nativeEvent.actionName === 'decrement') { + next = currentValues[0] - stepVal; + } + const clamped = clamp( + snapToStep(next, min, max, step), + min, + currentValues[1] - minSeparation + ); + if (clamped !== currentValues[0]) { + const nextVals: [number, number] = [clamped, currentValues[1]]; + if (!isControlled) { + setInternalValues(nextVals); + } + onValueChange?.(nextVals); + onSlidingComplete?.(nextVals); + } + }, + [ + disabled, + step, + max, + min, + currentValues, + minSeparation, + isControlled, + onValueChange, + onSlidingComplete, + ] + ); + + const handleEndAccessibilityAction = React.useCallback( + (event: { nativeEvent: { actionName: string } }) => { + if (disabled) return; + const stepVal = step ?? (max - min) / 100; + let next = currentValues[1]; + if (event.nativeEvent.actionName === 'increment') { + next = currentValues[1] + stepVal; + } else if (event.nativeEvent.actionName === 'decrement') { + next = currentValues[1] - stepVal; + } + const clamped = clamp( + snapToStep(next, min, max, step), + currentValues[0] + minSeparation, + max + ); + if (clamped !== currentValues[1]) { + const nextVals: [number, number] = [currentValues[0], clamped]; + if (!isControlled) { + setInternalValues(nextVals); + } + onValueChange?.(nextVals); + onSlidingComplete?.(nextVals); + } + }, + [ + disabled, + step, + max, + min, + currentValues, + minSeparation, + isControlled, + onValueChange, + onSlidingComplete, + ] + ); + + const handleStartKeyDown = React.useCallback( + (e: React.KeyboardEvent) => { + if (disabled) return; + const stepVal = step ?? (max - min) / 100; + let next = currentValues[0]; + if (e.key === 'ArrowRight' || e.key === 'ArrowUp') { + next = isRTL ? next - stepVal : next + stepVal; + e.preventDefault(); + } else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') { + next = isRTL ? next + stepVal : next - stepVal; + e.preventDefault(); + } else if (e.key === 'Home') { + next = min; + e.preventDefault(); + } else if (e.key === 'End') { + next = currentValues[1] - minSeparation; + e.preventDefault(); + } else if (e.key === 'PageUp') { + next = next + stepVal * 10; + e.preventDefault(); + } else if (e.key === 'PageDown') { + next = next - stepVal * 10; + e.preventDefault(); + } + const clamped = clamp( + snapToStep(next, min, max, step), + min, + currentValues[1] - minSeparation + ); + if (clamped !== currentValues[0]) { + const nextVals: [number, number] = [clamped, currentValues[1]]; + setInternalValues(nextVals); + onValueChange?.(nextVals); + onSlidingComplete?.(nextVals); + } + }, + [ + disabled, + step, + max, + min, + currentValues, + isRTL, + minSeparation, + onValueChange, + onSlidingComplete, + ] + ); + + const handleEndKeyDown = React.useCallback( + (e: React.KeyboardEvent) => { + if (disabled) return; + const stepVal = step ?? (max - min) / 100; + let next = currentValues[1]; + if (e.key === 'ArrowRight' || e.key === 'ArrowUp') { + next = isRTL ? next - stepVal : next + stepVal; + e.preventDefault(); + } else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') { + next = isRTL ? next + stepVal : next - stepVal; + e.preventDefault(); + } else if (e.key === 'Home') { + next = currentValues[0] + minSeparation; + e.preventDefault(); + } else if (e.key === 'End') { + next = max; + e.preventDefault(); + } else if (e.key === 'PageUp') { + next = next + stepVal * 10; + e.preventDefault(); + } else if (e.key === 'PageDown') { + next = next - stepVal * 10; + e.preventDefault(); + } + const clamped = clamp( + snapToStep(next, min, max, step), + currentValues[0] + minSeparation, + max + ); + if (clamped !== currentValues[1]) { + const nextVals: [number, number] = [currentValues[0], clamped]; + setInternalValues(nextVals); + onValueChange?.(nextVals); + onSlidingComplete?.(nextVals); + } + }, + [ + disabled, + step, + max, + min, + currentValues, + isRTL, + minSeparation, + onValueChange, + onSlidingComplete, + ] + ); + + const trackCornerRadius = trackHeight / 2 || DEFAULT_TRACK_CORNER_RADIUS; + const travelWidth = Math.max(0, trackWidth - 2 * trackCornerRadius); + + const startRatio = getRatioFromValue(currentValues[0], min, max); + const endRatio = getRatioFromValue(currentValues[1], min, max); + + const visualStartRatio = isRTL ? 1 - startRatio : startRatio; + const visualEndRatio = isRTL ? 1 - endRatio : endRatio; + + const startThumbCenter = trackCornerRadius + visualStartRatio * travelWidth; + const endThumbCenter = trackCornerRadius + visualEndRatio * travelWidth; + + const gap = thumbTrackGapSize; + const halfThumb = DEFAULT_THUMB_WIDTH / 2; + + // Active track is between the two thumbs (with gap on each side) + const leftEdge = Math.min(startThumbCenter, endThumbCenter); + const rightEdge = Math.max(startThumbCenter, endThumbCenter); + + const activeLeft = leftEdge + halfThumb + gap; + const activeWidth = Math.max(0, rightEdge - halfThumb - gap - activeLeft); + + // Inactive segments on the left and right + const inactiveLeftWidth = Math.max(0, leftEdge - halfThumb - gap); + const inactiveRightLeft = rightEdge + halfThumb + gap; + const inactiveRightWidth = Math.max(0, trackWidth - inactiveRightLeft); + + const effectiveActiveTrackColor = disabled + ? defaultSliderColors.disabledActiveTrackColor + : (activeColor ?? defaultSliderColors.activeTrackColor); + + const effectiveInactiveTrackColor = disabled + ? defaultSliderColors.disabledInactiveTrackColor + : (inactiveColor ?? defaultSliderColors.inactiveTrackColor); + + const effectiveThumbColor = disabled + ? defaultSliderColors.disabledHandleColor + : (thumbColor ?? defaultSliderColors.handleColor); + + const effectiveIconColor = disabled + ? defaultSliderColors.disabledIconColor + : defaultSliderColors.iconColor; + + const animDuration = reduceMotion ? 0 : 150; + + const startThumbAnimatedStyle = useAnimatedStyle(() => ({ + transform: [ + { + scaleX: withTiming(isInteractingStartSV.value ? 1.5 : 1, { + duration: animDuration, + }), + }, + ], + })); + + const endThumbAnimatedStyle = useAnimatedStyle(() => ({ + transform: [ + { + scaleX: withTiming(isInteractingEndSV.value ? 1.5 : 1, { + duration: animDuration, + }), + }, + ], + })); + + const stateLayerStartAnimatedStyle = useAnimatedStyle(() => ({ + opacity: withTiming( + isInteractingStartSV.value ? 0.12 : isStartFocused ? 0.1 : 0, + { + duration: animDuration, + } + ), + transform: [ + { + scale: withTiming( + isInteractingStartSV.value || isStartFocused ? 1 : 0.6, + { + duration: animDuration, + } + ), + }, + ], + })); + + const stateLayerEndAnimatedStyle = useAnimatedStyle(() => ({ + opacity: withTiming( + isInteractingEndSV.value ? 0.12 : isEndFocused ? 0.1 : 0, + { + duration: animDuration, + } + ), + transform: [ + { + scale: withTiming(isInteractingEndSV.value || isEndFocused ? 1 : 0.6, { + duration: animDuration, + }), + }, + ], + })); + + const valueIndicatorStartAnimatedStyle = useAnimatedStyle(() => { + if (labelBehavior === 'visible') { + return { opacity: 1, transform: [{ scale: 1 }] }; + } + if (labelBehavior === 'gone') { + return { opacity: 0, transform: [{ scale: 0 }] }; + } + const show = isInteractingStartSV.value === 1 || isStartFocused; + return { + opacity: withTiming(show ? 1 : 0, { duration: animDuration }), + transform: [ + { + scale: withTiming(show ? 1 : 0.8, { duration: animDuration }), + }, + ], + }; + }); + + const valueIndicatorEndAnimatedStyle = useAnimatedStyle(() => { + if (labelBehavior === 'visible') { + return { opacity: 1, transform: [{ scale: 1 }] }; + } + if (labelBehavior === 'gone') { + return { opacity: 0, transform: [{ scale: 0 }] }; + } + const show = isInteractingEndSV.value === 1 || isEndFocused; + return { + opacity: withTiming(show ? 1 : 0, { duration: animDuration }), + transform: [ + { + scale: withTiming(show ? 1 : 0.8, { duration: animDuration }), + }, + ], + }; + }); + + const tickMarks = React.useMemo( + () => (showTickMarks ? getTickMarks(min, max, step) : []), + [showTickMarks, min, max, step] + ); + + const startFormatted = formatValueIndicator + ? formatValueIndicator(currentValues[0]) + : String(currentValues[0]); + + const endFormatted = formatValueIndicator + ? formatValueIndicator(currentValues[1]) + : String(currentValues[1]); + + return ( + + {/* Leading Icon */} + {startIcon ? ( + + + + ) : null} + + {/* Main Track & Dual Thumbs Area */} + + {/* Inactive Left Track */} + {trackWidth > 0 && inactiveLeftWidth > 0 ? ( + + ) : null} + + {/* Active Middle Track */} + {trackWidth > 0 && activeWidth > 0 ? ( + + ) : null} + + {/* Inactive Right Track */} + {trackWidth > 0 && inactiveRightWidth > 0 ? ( + + ) : null} + + {/* Stop Indicators (Tick Marks) */} + {trackWidth > 0 && tickMarks.length > 0 + ? tickMarks.map((tickRatio, idx) => { + const tickVisualRatio = isRTL ? 1 - tickRatio : tickRatio; + const tickPos = trackCornerRadius + tickVisualRatio * travelWidth; + const isActiveTick = + tickRatio >= startRatio && tickRatio <= endRatio; + + // Don't render ticks inside thumb gaps + const distToStartThumb = Math.abs(tickPos - startThumbCenter); + const distToEndThumb = Math.abs(tickPos - endThumbCenter); + if ( + distToStartThumb < halfThumb + gap || + distToEndThumb < halfThumb + gap + ) { + return null; + } + + const tickColor = isActiveTick + ? defaultSliderColors.stopIndicatorActiveColor + : defaultSliderColors.stopIndicatorInactiveColor; + + return ( + + ); + }) + : null} + + {/* Start State Layer & Thumb */} + + setIsStartFocused(true), + onBlur: () => setIsStartFocused(false), + onKeyDown: handleStartKeyDown, + tabIndex: disabled ? -1 : 0, + } + : {})} + /> + {labelBehavior !== 'gone' ? ( + + + + {startFormatted} + + + + ) : null} + + {/* End State Layer & Thumb */} + + setIsEndFocused(true), + onBlur: () => setIsEndFocused(false), + onKeyDown: handleEndKeyDown, + tabIndex: disabled ? -1 : 0, + } + : {})} + /> + {labelBehavior !== 'gone' ? ( + + + + {endFormatted} + + + + ) : null} + + + {/* Trailing Icon */} + {endIcon ? ( + + + + ) : null} + + ); +}; + +// Web-only style; not in StyleSheet because `outline` is outside ViewStyle. +// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion +const webNoOutline = { outline: 'none' } as unknown as ViewStyle; + +const styles = StyleSheet.create({ + container: { + flexDirection: 'row', + alignItems: 'center', + width: '100%', + minHeight: MIN_TOUCH_TARGET_SIZE, + }, + touchArea: { + flex: 1, + height: MIN_TOUCH_TARGET_SIZE, + justifyContent: 'center', + position: 'relative', + overflow: 'visible', + }, + segment: { + position: 'absolute', + pointerEvents: 'none', + }, + inactiveLeftSegment: { + left: 0, + }, + thumb: { + position: 'absolute', + width: DEFAULT_THUMB_WIDTH, + height: DEFAULT_THUMB_HEIGHT, + borderRadius: THUMB_CORNER_RADIUS, + elevation: 1, + pointerEvents: 'none', + }, + stateLayer: { + position: 'absolute', + width: STATE_LAYER_SIZE, + height: STATE_LAYER_SIZE, + borderRadius: cornerFull, + pointerEvents: 'none', + }, + tickMark: { + position: 'absolute', + width: STOP_INDICATOR_SIZE, + height: STOP_INDICATOR_SIZE, + borderRadius: STOP_INDICATOR_SIZE / 2, + pointerEvents: 'none', + }, + valueIndicatorAnchor: { + position: 'absolute', + top: + (MIN_TOUCH_TARGET_SIZE - DEFAULT_THUMB_HEIGHT) / 2 - + VALUE_INDICATOR_GAP - + VALUE_INDICATOR_HEIGHT, + height: VALUE_INDICATOR_HEIGHT, + alignItems: 'center', + justifyContent: 'center', + }, + valueIndicator: { + flexDirection: 'row', + height: VALUE_INDICATOR_HEIGHT, + minWidth: VALUE_INDICATOR_MIN_WIDTH, + paddingHorizontal: VALUE_INDICATOR_PADDING_HORIZONTAL, + borderRadius: VALUE_INDICATOR_CORNER_RADIUS, + alignItems: 'center', + justifyContent: 'center', + }, + valueIndicatorText: { + fontWeight: '500', + textAlign: 'center', + flexShrink: 0, + }, + iconWrap: { + justifyContent: 'center', + alignItems: 'center', + }, + startIconWrap: { + marginRight: DEFAULT_ICON_GAP, + }, + endIconWrap: { + marginLeft: DEFAULT_ICON_GAP, + }, + disabledActiveTrack: { + opacity: DISABLED_ACTIVE_TRACK_OPACITY, + }, + disabledInactiveTrack: { + opacity: DISABLED_INACTIVE_TRACK_OPACITY, + }, + disabledHandle: { + opacity: DISABLED_HANDLE_OPACITY, + }, + disabledStopIndicator: { + opacity: DISABLED_STOP_INDICATOR_OPACITY, + }, + disabledIcon: { + opacity: DISABLED_ICON_OPACITY, + }, +}); + +export default RangeSlider; diff --git a/src/components/Slider/Slider.tsx b/src/components/Slider/Slider.tsx new file mode 100644 index 0000000000..a09f259ffd --- /dev/null +++ b/src/components/Slider/Slider.tsx @@ -0,0 +1,996 @@ +import * as React from 'react'; +import { + type GestureResponderEvent, + type LayoutChangeEvent, + Platform, + StyleSheet, + type StyleProp, + View, + type ViewStyle, +} from 'react-native'; + +import Animated, { + useAnimatedStyle, + useSharedValue, + withTiming, +} from 'react-native-reanimated'; + +import { SliderTokens } from './tokens'; +import { + clamp, + getDefaultSliderColors, + getRatioFromValue, + getTickMarks, + getValueFromRatio, + snapToStep, +} 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 '../../theme/types'; +import Icon, { type IconSource } from '../Icon'; +import Text from '../Typography/Text'; + +export type Props = { + /** + * Current value of the slider (controlled). + */ + value?: number; + /** + * Initial value when uncontrolled. Defaults to `min`. + */ + defaultValue?: number; + /** + * Minimum value of the slider. Defaults to 0. + */ + min?: number; + /** + * Maximum value of the slider. Defaults to 100. + */ + max?: number; + /** + * Step increment for discrete sliders. + */ + step?: number; + /** + * Whether to render stop indicators (tick marks) along the track. + * Defaults to `true` if `step` is provided, `false` otherwise. + */ + showTickMarks?: boolean; + /** + * Whether this is a centered slider, where the active track originates from the center or custom origin. + */ + centered?: boolean; + /** + * Custom origin value for centered sliders. Defaults to `(min + max) / 2` when `centered` is true. + */ + origin?: number; + /** + * Behavior of the value indicator tooltip. + * - `'floating'`: appears when interacting (default) + * - `'visible'`: always visible + * - `'gone'`: never shown + */ + labelBehavior?: 'floating' | 'visible' | 'gone'; + /** + * Function to format the text inside the value indicator. + */ + formatValueIndicator?: (value: number) => string; + /** + * Icon displayed before the slider track. + */ + startIcon?: IconSource; + /** + * Icon displayed after the slider track. + */ + endIcon?: IconSource; + /** + * Callback called when the value changes during dragging. + */ + onValueChange?: (value: number) => void; + /** + * Callback called when interaction begins. + */ + onSlidingStart?: (value: number) => void; + /** + * Callback called when interaction ends. + */ + onSlidingComplete?: (value: number) => void; + /** + * Disables interaction and renders the disabled visual state. + */ + disabled?: boolean; + /** + * Custom height for the track (defaults to 16dp per M3 specs). + */ + trackHeight?: number; + /** + * Custom gap size between thumb and track (defaults to 6dp per M3 specs). + */ + thumbTrackGapSize?: number; + /** + * Custom color for the active track. + */ + activeColor?: string; + /** + * Custom color for the inactive track. + */ + inactiveColor?: string; + /** + * Custom color for the thumb handle. + */ + thumbColor?: string; + style?: StyleProp; + testID?: string; + theme?: ThemeProp; + /** + * Accessibility label for the slider. + */ + 'aria-label'?: string; + accessibilityLabel?: string; +}; + +const { + trackHeight: DEFAULT_TRACK_HEIGHT, + trackCornerRadius: DEFAULT_TRACK_CORNER_RADIUS, + trackInsideCornerRadius: TRACK_INSIDE_CORNER_RADIUS, + thumbWidth: DEFAULT_THUMB_WIDTH, + thumbHeight: DEFAULT_THUMB_HEIGHT, + thumbCornerRadius: THUMB_CORNER_RADIUS, + thumbTrackGapSize: DEFAULT_GAP_SIZE, + stopIndicatorSize: STOP_INDICATOR_SIZE, + minTouchTargetSize: MIN_TOUCH_TARGET_SIZE, + stateLayerSize: STATE_LAYER_SIZE, + valueIndicatorHeight: VALUE_INDICATOR_HEIGHT, + valueIndicatorMinWidth: VALUE_INDICATOR_MIN_WIDTH, + valueIndicatorPaddingHorizontal: VALUE_INDICATOR_PADDING_HORIZONTAL, + valueIndicatorCornerRadius: VALUE_INDICATOR_CORNER_RADIUS, + valueIndicatorGap: VALUE_INDICATOR_GAP, + iconSize: DEFAULT_ICON_SIZE, + iconGap: DEFAULT_ICON_GAP, + disabledActiveTrackOpacity: DISABLED_ACTIVE_TRACK_OPACITY, + disabledInactiveTrackOpacity: DISABLED_INACTIVE_TRACK_OPACITY, + disabledHandleOpacity: DISABLED_HANDLE_OPACITY, + disabledStopIndicatorOpacity: DISABLED_STOP_INDICATOR_OPACITY, + disabledIconOpacity: DISABLED_ICON_OPACITY, +} = SliderTokens; + +/** + * Material Design 3 Slider component. + * + * Sliders let users make selections from a range of values. + * Supports continuous, discrete (stops/ticks), centered, and custom origin configurations. + * + * ## Usage + * ```js + * import * as React from 'react'; + * import { Slider } from 'react-native-paper'; + * + * const Example = () => { + * const [value, setValue] = React.useState(50); + * return ; + * }; + * ``` + */ +const Slider = ({ + value: controlledValue, + defaultValue, + min = 0, + max = 100, + step, + showTickMarks = step !== undefined, + centered = false, + origin: customOrigin, + labelBehavior = 'floating', + formatValueIndicator, + startIcon, + endIcon, + onValueChange, + onSlidingStart, + onSlidingComplete, + disabled = false, + trackHeight = DEFAULT_TRACK_HEIGHT, + thumbTrackGapSize = DEFAULT_GAP_SIZE, + activeColor, + inactiveColor, + thumbColor, + style, + testID, + theme: themeOverrides, + 'aria-label': ariaLabelProp, + accessibilityLabel: accessibilityLabelProp, +}: Props) => { + const theme = useInternalTheme(themeOverrides); + const reduceMotion = useReduceMotion(); + const { direction } = useLocale(); + const isRTL = direction === 'rtl'; + + const defaultSliderColors = React.useMemo( + () => getDefaultSliderColors(theme), + [theme] + ); + + const isControlled = controlledValue !== undefined; + const initialVal = controlledValue ?? defaultValue ?? min; + const [internalValue, setInternalValue] = React.useState( + snapToStep(initialVal, min, max, step) + ); + + const currentValue = isControlled + ? snapToStep(controlledValue, min, max, step) + : internalValue; + + const [trackWidth, setTrackWidth] = React.useState(0); + const trackRef = React.useRef(null); + const trackPageX = React.useRef(0); + + const isInteractingSV = useSharedValue(0); + const [isFocused, setIsFocused] = React.useState(false); + const [indicatorWidth, setIndicatorWidth] = React.useState( + VALUE_INDICATOR_MIN_WIDTH + ); + + const handleIndicatorLayout = React.useCallback( + (e: LayoutChangeEvent) => { + const w = e.nativeEvent.layout.width; + if (w > 0 && Math.abs(w - indicatorWidth) > 0.5) { + setIndicatorWidth(w); + } + }, + [indicatorWidth] + ); + + const latestCallbacks = React.useRef({ + onValueChange, + onSlidingStart, + onSlidingComplete, + currentValue, + min, + max, + step, + disabled, + isRTL, + isControlled, + }); + + React.useEffect(() => { + latestCallbacks.current = { + onValueChange, + onSlidingStart, + onSlidingComplete, + currentValue, + min, + max, + step, + disabled, + isRTL, + isControlled, + }; + }); + + const updateValueFromX = React.useCallback( + (x: number) => { + const { + min: curMin, + max: curMax, + step: curStep, + isRTL: curRTL, + onValueChange: cbValueChange, + currentValue: curVal, + isControlled: curControlled, + } = latestCallbacks.current; + + const radius = trackHeight / 2 || DEFAULT_TRACK_CORNER_RADIUS; + const travelWidth = Math.max(0, trackWidth - 2 * radius); + if (travelWidth <= 0) return; + + let ratio = clamp((x - radius) / travelWidth, 0, 1); + if (curRTL) { + ratio = 1 - ratio; + } + + const nextVal = getValueFromRatio(ratio, curMin, curMax, curStep); + if (nextVal !== curVal) { + if (!curControlled) { + setInternalValue(nextVal); + } + cbValueChange?.(nextVal); + } + }, + [trackHeight, trackWidth] + ); + + const handleStartShouldSetResponder = React.useCallback( + () => !disabled, + [disabled] + ); + + const handleMoveShouldSetResponder = React.useCallback( + () => !disabled, + [disabled] + ); + + const handleTerminationRequest = React.useCallback(() => false, []); + + const handleResponderGrant = React.useCallback( + (evt: GestureResponderEvent) => { + if (disabled) return false; + isInteractingSV.value = 1; + onSlidingStart?.(currentValue); + + const pageX = evt.nativeEvent.pageX; + const locationX = evt.nativeEvent.locationX; + trackPageX.current = pageX - locationX; + updateValueFromX(locationX); + + if (trackRef.current) { + trackRef.current.measure((_x, _y, _width, _height, measuredPageX) => { + if (!isNaN(measuredPageX)) { + trackPageX.current = measuredPageX; + } + }); + } + return true; + }, + [disabled, isInteractingSV, onSlidingStart, currentValue, updateValueFromX] + ); + + const handleResponderMove = React.useCallback( + (evt: GestureResponderEvent) => { + if (disabled) return; + const localX = evt.nativeEvent.pageX - trackPageX.current; + updateValueFromX(localX); + }, + [disabled, updateValueFromX] + ); + + const handleResponderEnd = React.useCallback(() => { + isInteractingSV.value = 0; + onSlidingComplete?.(currentValue); + }, [isInteractingSV, onSlidingComplete, currentValue]); + + const onTrackLayout = React.useCallback((e: LayoutChangeEvent) => { + const { width } = e.nativeEvent.layout; + setTrackWidth(width); + if (trackRef.current) { + trackRef.current.measure((_x, _y, _width, _height, pageX) => { + trackPageX.current = pageX; + }); + } + }, []); + + const handleAccessibilityAction = React.useCallback( + (event: { nativeEvent: { actionName: string } }) => { + if (disabled) return; + const stepVal = step ?? (max - min) / 100; + let next = currentValue; + if (event.nativeEvent.actionName === 'increment') { + next = currentValue + stepVal; + } else if (event.nativeEvent.actionName === 'decrement') { + next = currentValue - stepVal; + } + const clamped = snapToStep(next, min, max, step); + if (clamped !== currentValue) { + if (!isControlled) { + setInternalValue(clamped); + } + onValueChange?.(clamped); + onSlidingComplete?.(clamped); + } + }, + [ + disabled, + step, + max, + min, + currentValue, + isControlled, + onValueChange, + onSlidingComplete, + ] + ); + + const handleKeyDown = React.useCallback( + (e: React.KeyboardEvent) => { + if (disabled) return; + const stepVal = step ?? (max - min) / 100; + let next = currentValue; + + if (e.key === 'ArrowRight' || e.key === 'ArrowUp') { + next = isRTL ? currentValue - stepVal : currentValue + stepVal; + e.preventDefault(); + } else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') { + next = isRTL ? currentValue + stepVal : currentValue - stepVal; + e.preventDefault(); + } else if (e.key === 'Home') { + next = min; + e.preventDefault(); + } else if (e.key === 'End') { + next = max; + e.preventDefault(); + } else if (e.key === 'PageUp') { + next = currentValue + stepVal * 10; + e.preventDefault(); + } else if (e.key === 'PageDown') { + next = currentValue - stepVal * 10; + e.preventDefault(); + } + + const clamped = snapToStep(next, min, max, step); + if (clamped !== currentValue) { + setInternalValue(clamped); + onValueChange?.(clamped); + onSlidingComplete?.(clamped); + } + }, + [ + disabled, + step, + max, + min, + currentValue, + isRTL, + onValueChange, + onSlidingComplete, + ] + ); + + const trackCornerRadius = trackHeight / 2 || DEFAULT_TRACK_CORNER_RADIUS; + const travelWidth = Math.max(0, trackWidth - 2 * trackCornerRadius); + + const ratio = getRatioFromValue(currentValue, min, max); + const visualRatio = isRTL ? 1 - ratio : ratio; + const thumbCenter = trackCornerRadius + visualRatio * travelWidth; + + const effectiveOrigin = centered + ? (customOrigin ?? (min + max) / 2) + : undefined; + const originRatio = + effectiveOrigin !== undefined + ? getRatioFromValue(effectiveOrigin, min, max) + : 0; + const visualOriginRatio = isRTL ? 1 - originRatio : originRatio; + const originCenter = trackCornerRadius + visualOriginRatio * travelWidth; + + const effectiveActiveTrackColor = disabled + ? defaultSliderColors.disabledActiveTrackColor + : (activeColor ?? defaultSliderColors.activeTrackColor); + + const effectiveInactiveTrackColor = disabled + ? defaultSliderColors.disabledInactiveTrackColor + : (inactiveColor ?? defaultSliderColors.inactiveTrackColor); + + const effectiveThumbColor = disabled + ? defaultSliderColors.disabledHandleColor + : (thumbColor ?? defaultSliderColors.handleColor); + + const effectiveIconColor = disabled + ? defaultSliderColors.disabledIconColor + : defaultSliderColors.iconColor; + + const animDuration = reduceMotion ? 0 : 150; + + const stateLayerAnimatedStyle = useAnimatedStyle(() => ({ + opacity: withTiming(isInteractingSV.value ? 0.12 : isFocused ? 0.1 : 0, { + duration: animDuration, + }), + transform: [ + { + scale: withTiming(isInteractingSV.value || isFocused ? 1 : 0.6, { + duration: animDuration, + }), + }, + ], + })); + + const thumbAnimatedStyle = useAnimatedStyle(() => ({ + transform: [ + { + scaleX: withTiming(isInteractingSV.value ? 1.5 : 1, { + duration: animDuration, + }), + }, + ], + })); + + const valueIndicatorAnimatedStyle = useAnimatedStyle(() => { + if (labelBehavior === 'visible') { + return { opacity: 1, transform: [{ scale: 1 }] }; + } + if (labelBehavior === 'gone') { + return { opacity: 0, transform: [{ scale: 0 }] }; + } + const show = isInteractingSV.value === 1 || isFocused; + return { + opacity: withTiming(show ? 1 : 0, { duration: animDuration }), + transform: [ + { + scale: withTiming(show ? 1 : 0.8, { duration: animDuration }), + }, + ], + }; + }); + + const gap = thumbTrackGapSize; + const halfThumb = DEFAULT_THUMB_WIDTH / 2; + + let activeLeft = 0; + let activeWidth = 0; + let activeBorderRadius: ViewStyle = {}; + + let inactiveLeftWidth = 0; + let inactiveLeftRadius: ViewStyle = {}; + + let inactiveRightLeft = 0; + let inactiveRightWidth = 0; + let inactiveRightRadius: ViewStyle = {}; + + if (effectiveOrigin !== undefined) { + if (thumbCenter >= originCenter) { + activeLeft = originCenter; + activeWidth = Math.max(0, thumbCenter - originCenter - halfThumb - gap); + activeBorderRadius = { + borderRadius: TRACK_INSIDE_CORNER_RADIUS, + }; + + const inactiveLeftEnd = + thumbCenter - halfThumb - gap < originCenter + ? Math.max(0, thumbCenter - halfThumb - gap) + : originCenter; + inactiveLeftWidth = inactiveLeftEnd; + inactiveLeftRadius = { + borderTopLeftRadius: trackCornerRadius, + borderBottomLeftRadius: trackCornerRadius, + borderTopRightRadius: TRACK_INSIDE_CORNER_RADIUS, + borderBottomRightRadius: TRACK_INSIDE_CORNER_RADIUS, + }; + + inactiveRightLeft = thumbCenter + halfThumb + gap; + inactiveRightWidth = Math.max(0, trackWidth - inactiveRightLeft); + inactiveRightRadius = { + borderTopLeftRadius: TRACK_INSIDE_CORNER_RADIUS, + borderBottomLeftRadius: TRACK_INSIDE_CORNER_RADIUS, + borderTopRightRadius: trackCornerRadius, + borderBottomRightRadius: trackCornerRadius, + }; + } else { + activeLeft = thumbCenter + halfThumb + gap; + activeWidth = Math.max(0, originCenter - activeLeft); + activeBorderRadius = { + borderRadius: TRACK_INSIDE_CORNER_RADIUS, + }; + + inactiveLeftWidth = Math.max(0, thumbCenter - halfThumb - gap); + inactiveLeftRadius = { + borderTopLeftRadius: trackCornerRadius, + borderBottomLeftRadius: trackCornerRadius, + borderTopRightRadius: TRACK_INSIDE_CORNER_RADIUS, + borderBottomRightRadius: TRACK_INSIDE_CORNER_RADIUS, + }; + + const inactiveRightStart = + thumbCenter + halfThumb + gap > originCenter + ? thumbCenter + halfThumb + gap + : originCenter; + inactiveRightLeft = inactiveRightStart; + inactiveRightWidth = Math.max(0, trackWidth - inactiveRightLeft); + inactiveRightRadius = { + borderTopLeftRadius: TRACK_INSIDE_CORNER_RADIUS, + borderBottomLeftRadius: TRACK_INSIDE_CORNER_RADIUS, + borderTopRightRadius: trackCornerRadius, + borderBottomRightRadius: trackCornerRadius, + }; + } + } else { + if (!isRTL) { + activeLeft = 0; + activeWidth = Math.max(0, thumbCenter - halfThumb - gap); + activeBorderRadius = { + borderTopLeftRadius: trackCornerRadius, + borderBottomLeftRadius: trackCornerRadius, + borderTopRightRadius: TRACK_INSIDE_CORNER_RADIUS, + borderBottomRightRadius: TRACK_INSIDE_CORNER_RADIUS, + }; + + inactiveRightLeft = thumbCenter + halfThumb + gap; + inactiveRightWidth = Math.max(0, trackWidth - inactiveRightLeft); + inactiveRightRadius = { + borderTopLeftRadius: TRACK_INSIDE_CORNER_RADIUS, + borderBottomLeftRadius: TRACK_INSIDE_CORNER_RADIUS, + borderTopRightRadius: trackCornerRadius, + borderBottomRightRadius: trackCornerRadius, + }; + } else { + inactiveLeftWidth = Math.max(0, thumbCenter - halfThumb - gap); + inactiveLeftRadius = { + borderTopLeftRadius: trackCornerRadius, + borderBottomLeftRadius: trackCornerRadius, + borderTopRightRadius: TRACK_INSIDE_CORNER_RADIUS, + borderBottomRightRadius: TRACK_INSIDE_CORNER_RADIUS, + }; + + activeLeft = thumbCenter + halfThumb + gap; + activeWidth = Math.max(0, trackWidth - activeLeft); + activeBorderRadius = { + borderTopLeftRadius: TRACK_INSIDE_CORNER_RADIUS, + borderBottomLeftRadius: TRACK_INSIDE_CORNER_RADIUS, + borderTopRightRadius: trackCornerRadius, + borderBottomRightRadius: trackCornerRadius, + }; + } + } + + const tickMarks = React.useMemo( + () => (showTickMarks ? getTickMarks(min, max, step) : []), + [showTickMarks, min, max, step] + ); + + const formattedLabel = formatValueIndicator + ? formatValueIndicator(currentValue) + : String(currentValue); + + const accessibilityLabel = + ariaLabelProp ?? accessibilityLabelProp ?? 'Slider'; + + const inactiveLeftTrackDynamicStyle = { + height: trackHeight, + width: inactiveLeftWidth, + backgroundColor: effectiveInactiveTrackColor, + ...inactiveLeftRadius, + }; + + const activeTrackDynamicStyle = { + height: trackHeight, + left: activeLeft, + width: activeWidth, + backgroundColor: effectiveActiveTrackColor, + ...activeBorderRadius, + }; + + const inactiveRightTrackDynamicStyle = { + height: trackHeight, + left: inactiveRightLeft, + width: inactiveRightWidth, + backgroundColor: effectiveInactiveTrackColor, + ...inactiveRightRadius, + }; + + const stateLayerDynamicStyle = { + left: thumbCenter - STATE_LAYER_SIZE / 2, + top: (MIN_TOUCH_TARGET_SIZE - STATE_LAYER_SIZE) / 2, + backgroundColor: defaultSliderColors.stateLayerColor, + }; + + const thumbDynamicStyle = { + left: thumbCenter - DEFAULT_THUMB_WIDTH / 2, + top: (MIN_TOUCH_TARGET_SIZE - DEFAULT_THUMB_HEIGHT) / 2, + backgroundColor: effectiveThumbColor, + }; + + const valueIndicatorAnchorDynamicStyle = { + left: thumbCenter - indicatorWidth / 2, + }; + + const valueIndicatorDynamicStyle = { + backgroundColor: defaultSliderColors.valueIndicatorContainerColor, + }; + + const valueIndicatorTextDynamicStyle = { + color: defaultSliderColors.valueIndicatorTextColor, + }; + + return ( + setIsFocused(true), + onBlur: () => setIsFocused(false), + } + : {})} + accessible + accessibilityRole="adjustable" + role="slider" + aria-label={accessibilityLabel} + aria-valuemin={min} + aria-valuemax={max} + aria-valuenow={currentValue} + aria-valuetext={formattedLabel} + aria-disabled={disabled} + accessibilityActions={[ + { name: 'increment', label: 'increment' }, + { name: 'decrement', label: 'decrement' }, + ]} + onAccessibilityAction={handleAccessibilityAction} + > + {/* Leading Icon */} + {startIcon ? ( + + + + ) : null} + + {/* Main Track & Thumb Interaction Area */} + + {/* Inactive Left Track */} + {trackWidth > 0 && inactiveLeftWidth > 0 ? ( + + ) : null} + + {/* Active Track Highlight Segment */} + {trackWidth > 0 && activeWidth > 0 ? ( + + ) : null} + + {/* Inactive Right Track */} + {trackWidth > 0 && inactiveRightWidth > 0 ? ( + + ) : null} + + {/* Stop Indicators (Tick Marks) */} + {trackWidth > 0 && tickMarks.length > 0 + ? tickMarks.map((tickRatio, idx) => { + const tickVisualRatio = isRTL ? 1 - tickRatio : tickRatio; + const tickPos = trackCornerRadius + tickVisualRatio * travelWidth; + const isActiveTick = + effectiveOrigin !== undefined + ? (tickRatio >= originRatio && tickRatio <= ratio) || + (tickRatio <= originRatio && tickRatio >= ratio) + : tickRatio <= ratio; + + const distToThumb = Math.abs(tickPos - thumbCenter); + if (distToThumb < halfThumb + gap) { + return null; + } + + const tickColor = isActiveTick + ? defaultSliderColors.stopIndicatorActiveColor + : defaultSliderColors.stopIndicatorInactiveColor; + + const tickDynamicStyle = { + left: tickPos - STOP_INDICATOR_SIZE / 2, + top: (MIN_TOUCH_TARGET_SIZE - STOP_INDICATOR_SIZE) / 2, + backgroundColor: disabled + ? defaultSliderColors.disabledStopIndicatorColor + : tickColor, + }; + + return ( + + ); + }) + : null} + + {/* State Layer (Ripple circle around thumb) */} + {trackWidth > 0 ? ( + + ) : null} + + {/* Thumb (Handle Bar) */} + {trackWidth > 0 ? ( + + ) : null} + + {/* Floating Value Indicator (Tooltip) */} + {trackWidth > 0 && labelBehavior !== 'gone' ? ( + + + + {formattedLabel} + + + + ) : null} + + + {/* Trailing Icon */} + {endIcon ? ( + + + + ) : null} + + ); +}; + +// Web-only style; not in StyleSheet because `outline` is outside ViewStyle. +// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion +const webNoOutline = { outline: 'none' } as unknown as ViewStyle; + +const styles = StyleSheet.create({ + container: { + flexDirection: 'row', + alignItems: 'center', + width: '100%', + minHeight: MIN_TOUCH_TARGET_SIZE, + }, + touchArea: { + flex: 1, + height: MIN_TOUCH_TARGET_SIZE, + justifyContent: 'center', + position: 'relative', + overflow: 'visible', + }, + segment: { + position: 'absolute', + pointerEvents: 'none', + }, + inactiveLeftSegment: { + left: 0, + }, + thumb: { + position: 'absolute', + width: DEFAULT_THUMB_WIDTH, + height: DEFAULT_THUMB_HEIGHT, + borderRadius: THUMB_CORNER_RADIUS, + elevation: 1, + pointerEvents: 'none', + }, + stateLayer: { + position: 'absolute', + width: STATE_LAYER_SIZE, + height: STATE_LAYER_SIZE, + borderRadius: cornerFull, + pointerEvents: 'none', + }, + tickMark: { + position: 'absolute', + width: STOP_INDICATOR_SIZE, + height: STOP_INDICATOR_SIZE, + borderRadius: STOP_INDICATOR_SIZE / 2, + pointerEvents: 'none', + }, + valueIndicatorAnchor: { + position: 'absolute', + top: + (MIN_TOUCH_TARGET_SIZE - DEFAULT_THUMB_HEIGHT) / 2 - + VALUE_INDICATOR_GAP - + VALUE_INDICATOR_HEIGHT, + height: VALUE_INDICATOR_HEIGHT, + alignItems: 'center', + justifyContent: 'center', + }, + valueIndicator: { + flexDirection: 'row', + height: VALUE_INDICATOR_HEIGHT, + minWidth: VALUE_INDICATOR_MIN_WIDTH, + paddingHorizontal: VALUE_INDICATOR_PADDING_HORIZONTAL, + borderRadius: VALUE_INDICATOR_CORNER_RADIUS, + alignItems: 'center', + justifyContent: 'center', + }, + valueIndicatorText: { + fontWeight: '500', + textAlign: 'center', + flexShrink: 0, + }, + iconWrap: { + justifyContent: 'center', + alignItems: 'center', + }, + startIconWrap: { + marginRight: DEFAULT_ICON_GAP, + }, + endIconWrap: { + marginLeft: DEFAULT_ICON_GAP, + }, + disabledActiveTrack: { + opacity: DISABLED_ACTIVE_TRACK_OPACITY, + }, + disabledInactiveTrack: { + opacity: DISABLED_INACTIVE_TRACK_OPACITY, + }, + disabledHandle: { + opacity: DISABLED_HANDLE_OPACITY, + }, + disabledStopIndicator: { + opacity: DISABLED_STOP_INDICATOR_OPACITY, + }, + disabledIcon: { + opacity: DISABLED_ICON_OPACITY, + }, +}); + +export default Slider; diff --git a/src/components/Slider/index.ts b/src/components/Slider/index.ts new file mode 100644 index 0000000000..eda5ac4721 --- /dev/null +++ b/src/components/Slider/index.ts @@ -0,0 +1,17 @@ +import RangeSliderComponent, { + type Props as RangeSliderProps, +} from './RangeSlider'; +import SliderComponent, { type Props as SliderProps } from './Slider'; + +const Slider = Object.assign( + // @component ./Slider.tsx + SliderComponent, + { + // @component ./RangeSlider.tsx + Range: RangeSliderComponent, + } +); + +export default Slider; +export { default as RangeSlider } from './RangeSlider'; +export type { SliderProps, RangeSliderProps }; diff --git a/src/components/Slider/tokens.ts b/src/components/Slider/tokens.ts new file mode 100644 index 0000000000..191a55f541 --- /dev/null +++ b/src/components/Slider/tokens.ts @@ -0,0 +1,72 @@ +import type { ColorRole } from '../../theme/types'; + +/** + * Material Design 3 Slider tokens. + * @see https://m3.material.io/components/sliders/specs + */ +const sizes = { + /** Height of the track in resting state */ + trackHeight: 16, + /** Corner radius for outer track ends */ + trackCornerRadius: 8, + /** Corner radius for inside track ends near the thumb-track gap */ + trackInsideCornerRadius: 2, + /** Width of the resting thumb bar */ + thumbWidth: 4, + /** Height of the resting thumb bar */ + thumbHeight: 44, + /** Corner radius of the thumb */ + thumbCornerRadius: 2, + /** Width of the thumb when pressed/interacted */ + pressedThumbWidth: 6, + /** Size of the gap between the thumb and the track */ + thumbTrackGapSize: 6, + /** Diameter of stop indicators / tick marks */ + stopIndicatorSize: 4, + /** Minimum touch target size for accessibility */ + minTouchTargetSize: 48, + /** Size of the interaction state layer circle */ + stateLayerSize: 40, + /** Height of the floating value indicator bubble */ + valueIndicatorHeight: 44, + /** Minimum width of the floating value indicator bubble */ + valueIndicatorMinWidth: 48, + /** Horizontal padding for the value indicator */ + valueIndicatorPaddingHorizontal: 12, + /** Corner radius of the value indicator (fully rounded pill / stadium shape) */ + valueIndicatorCornerRadius: 22, + /** Gap between handle and value indicator */ + valueIndicatorGap: 12, + /** Default size for leading/trailing inset icons */ + iconSize: 24, + /** Gap between leading/trailing icon and track */ + iconGap: 12, + + // Opacities for disabled state + disabledActiveTrackOpacity: 0.38, + disabledInactiveTrackOpacity: 0.12, + disabledHandleOpacity: 0.38, + disabledStopIndicatorOpacity: 0.38, + disabledIconOpacity: 0.38, +} as const; + +const colors = { + activeTrackColor: 'primary', + inactiveTrackColor: 'surfaceContainerHighest', + handleColor: 'primary', + stopIndicatorActiveColor: 'onPrimary', + stopIndicatorInactiveColor: 'onSurfaceVariant', + valueIndicatorContainerColor: 'inverseSurface', + valueIndicatorTextColor: 'inverseOnSurface', + stateLayerColor: 'primary', + iconColor: 'onSurfaceVariant', + + // Disabled colors + disabledActiveTrackColor: 'onSurface', + disabledInactiveTrackColor: 'onSurface', + disabledHandleColor: 'onSurface', + disabledStopIndicatorColor: 'onSurface', + disabledIconColor: 'onSurface', +} as const satisfies Record; + +export const SliderTokens = { ...sizes, ...colors }; diff --git a/src/components/Slider/utils.ts b/src/components/Slider/utils.ts new file mode 100644 index 0000000000..28a0b15925 --- /dev/null +++ b/src/components/Slider/utils.ts @@ -0,0 +1,137 @@ +import type { ColorValue } from 'react-native'; + +import { SliderTokens } from './tokens'; +import type { InternalTheme } from '../../theme/types'; + +export type SliderColors = { + activeTrackColor: ColorValue; + inactiveTrackColor: ColorValue; + handleColor: ColorValue; + stopIndicatorActiveColor: ColorValue; + stopIndicatorInactiveColor: ColorValue; + valueIndicatorContainerColor: ColorValue; + valueIndicatorTextColor: ColorValue; + stateLayerColor: ColorValue; + iconColor: ColorValue; + + disabledActiveTrackColor: ColorValue; + disabledInactiveTrackColor: ColorValue; + disabledHandleColor: ColorValue; + disabledStopIndicatorColor: ColorValue; + disabledIconColor: ColorValue; +}; + +export function getDefaultSliderColors(theme: InternalTheme): SliderColors { + const t = SliderTokens; + const c = theme.colors; + + return { + activeTrackColor: c[t.activeTrackColor], + inactiveTrackColor: c[t.inactiveTrackColor], + handleColor: c[t.handleColor], + stopIndicatorActiveColor: c[t.stopIndicatorActiveColor], + stopIndicatorInactiveColor: c[t.stopIndicatorInactiveColor], + valueIndicatorContainerColor: c[t.valueIndicatorContainerColor], + valueIndicatorTextColor: c[t.valueIndicatorTextColor], + stateLayerColor: c[t.stateLayerColor], + iconColor: c[t.iconColor], + + disabledActiveTrackColor: c[t.disabledActiveTrackColor], + disabledInactiveTrackColor: c[t.disabledInactiveTrackColor], + disabledHandleColor: c[t.disabledHandleColor], + disabledStopIndicatorColor: c[t.disabledStopIndicatorColor], + disabledIconColor: c[t.disabledIconColor], + }; +} + +/** + * Clamps a number between min and max inclusive. + */ +export function clamp(value: number, min: number, max: number): number { + if (value < min) return min; + if (value > max) return max; + return value; +} + +/** + * Snaps a value to the nearest step within [min, max]. + */ +export function snapToStep( + value: number, + min: number, + max: number, + step?: number +): number { + if (typeof step !== 'number' || step <= 0) { + return clamp(value, min, max); + } + + const stepsCount = Math.round((value - min) / step); + const snapped = min + stepsCount * step; + + // Fix possible floating point inaccuracies (e.g., 0.1 + 0.2 = 0.30000000000000004) + const precision = (step.toString().split('.')[1] || '').length; + const fixed = Number(snapped.toFixed(Math.max(precision, 4))); + + return clamp(fixed, min, max); +} + +/** + * Converts a value to a ratio between 0 and 1. + */ +export function getRatioFromValue( + value: number, + min: number, + max: number +): number { + if (max <= min) return 0; + return clamp((value - min) / (max - min), 0, 1); +} + +/** + * Converts a 0..1 ratio to a stepped value between min and max. + */ +export function getValueFromRatio( + ratio: number, + min: number, + max: number, + step?: number +): number { + const raw = min + ratio * (max - min); + return snapToStep(raw, min, max, step); +} + +/** + * Computes the normalized tick mark ratios [0..1] for a discrete slider. + */ +export function getTickMarks( + min: number, + max: number, + step?: number +): number[] { + if (typeof step !== 'number' || step <= 0 || max <= min) { + return []; + } + + const ticks: number[] = []; + const range = max - min; + const stepsCount = Math.floor(range / step); + + // Guard against extreme number of ticks causing performance degradation + if (stepsCount > 100) { + return []; + } + + for (let i = 0; i <= stepsCount; i++) { + const val = min + i * step; + ticks.push((val - min) / range); + } + + // Include the final max tick if not already added + const lastTick = ticks[ticks.length - 1]; + if (lastTick !== undefined && lastTick < 0.9999) { + ticks.push(1); + } + + return ticks; +} diff --git a/src/components/__tests__/Slider.test.tsx b/src/components/__tests__/Slider.test.tsx new file mode 100644 index 0000000000..8b6846998d --- /dev/null +++ b/src/components/__tests__/Slider.test.tsx @@ -0,0 +1,364 @@ +import { Platform } from 'react-native'; + +import { + afterEach, + beforeEach, + describe, + expect, + it, + jest, +} from '@jest/globals'; + +import { fireEvent, render, screen } from '../../test-utils'; +import RangeSlider from '../Slider/RangeSlider'; +import Slider from '../Slider/Slider'; +import { + clamp, + getRatioFromValue, + getTickMarks, + getValueFromRatio, + snapToStep, +} from '../Slider/utils'; + +describe('Slider utilities', () => { + it('clamps values within min and max', () => { + expect(clamp(150, 0, 100)).toBe(100); + expect(clamp(-50, 0, 100)).toBe(0); + expect(clamp(45, 0, 100)).toBe(45); + }); + + it('snaps values to nearest step', () => { + expect(snapToStep(23, 0, 100, 10)).toBe(20); + expect(snapToStep(27, 0, 100, 10)).toBe(30); + expect(snapToStep(0.24, 0, 1, 0.1)).toBe(0.2); + expect(snapToStep(0.26, 0, 1, 0.1)).toBe(0.3); + }); + + it('computes ratio and value accurately', () => { + expect(getRatioFromValue(50, 0, 100)).toBe(0.5); + expect(getRatioFromValue(0, -50, 50)).toBe(0.5); + expect(getValueFromRatio(0.5, 0, 100, 5)).toBe(50); + }); + + it('generates normalized tick marks', () => { + const ticks = getTickMarks(0, 100, 25); + expect(ticks).toEqual([0, 0.25, 0.5, 0.75, 1]); + }); +}); + +describe('Slider render', () => { + it('renders standard continuous slider', async () => { + const { toJSON } = await render(); + expect(toJSON()).toMatchSnapshot(); + }); + + it('renders discrete slider with tick marks', async () => { + const { toJSON } = await render( + + ); + expect(toJSON()).toMatchSnapshot(); + }); + + it('renders centered slider', async () => { + const { toJSON } = await render( + + ); + expect(toJSON()).toMatchSnapshot(); + }); + + it('renders disabled slider', async () => { + const { toJSON } = await render(); + expect(toJSON()).toMatchSnapshot(); + }); + + it('renders with start and end icons', async () => { + const { toJSON } = await render( + + ); + expect(toJSON()).toMatchSnapshot(); + }); + + it('renders with value indicator visible', async () => { + const { toJSON } = await render( + `${v}%`} + /> + ); + expect(toJSON()).toMatchSnapshot(); + }); +}); + +describe('Slider accessibility', () => { + it('has adjustable role and exposes accessibility value attributes', async () => { + await render( + `${v}%`} + /> + ); + + const slider = screen.getByRole('slider'); + expect(slider).toBeOnTheScreen(); + expect(slider).toHaveProp('aria-valuenow', 42); + expect(slider).toHaveProp('aria-valuemin', 0); + expect(slider).toHaveProp('aria-valuemax', 100); + expect(slider).toHaveProp('aria-valuetext', '42%'); + expect(slider).toHaveProp('aria-label', 'Volume'); + expect(slider).toBeEnabled(); + }); + + it('sets aria-disabled when disabled', async () => { + await render(); + + const slider = screen.getByRole('slider'); + expect(slider).toBeDisabled(); + expect(slider).toHaveProp('aria-disabled', true); + }); +}); + +describe('RangeSlider render & accessibility', () => { + it('renders range slider', async () => { + const { toJSON } = await render( + + ); + expect(toJSON()).toMatchSnapshot(); + }); + + it('renders disabled range slider', async () => { + const { toJSON } = await render(); + expect(toJSON()).toMatchSnapshot(); + }); + + it('has two accessible slider handles for range', async () => { + await render( + + ); + + const sliders = screen.getAllByRole('slider'); + expect(sliders).toHaveLength(2); + + expect(sliders[0]).toHaveProp('aria-label', 'Price minimum'); + expect(sliders[0]).toHaveProp('aria-valuenow', 25); + + expect(sliders[1]).toHaveProp('aria-label', 'Price maximum'); + expect(sliders[1]).toHaveProp('aria-valuenow', 75); + }); +}); + +describe('Slider interaction (web keyboard navigation)', () => { + const originalOS = Platform.OS; + + beforeEach(() => { + Platform.OS = 'web'; + }); + + afterEach(() => { + Platform.OS = originalOS; + }); + + it('navigates with ArrowRight and ArrowLeft', async () => { + const onValueChange = jest.fn(); + const onSlidingComplete = jest.fn(); + + await render( + + ); + + const slider = screen.getByRole('slider'); + await fireEvent(slider, 'keyDown', { + key: 'ArrowRight', + preventDefault: jest.fn(), + }); + expect(onValueChange).toHaveBeenCalledWith(55); + expect(onSlidingComplete).toHaveBeenCalledWith(55); + + await fireEvent(slider, 'keyDown', { + key: 'ArrowLeft', + preventDefault: jest.fn(), + }); + expect(onValueChange).toHaveBeenCalledWith(50); + }); + + it('navigates with Home and End', async () => { + const onValueChange = jest.fn(); + + await render( + + ); + + const slider = screen.getByRole('slider'); + await fireEvent(slider, 'keyDown', { + key: 'Home', + preventDefault: jest.fn(), + }); + expect(onValueChange).toHaveBeenCalledWith(0); + + await fireEvent(slider, 'keyDown', { + key: 'End', + preventDefault: jest.fn(), + }); + expect(onValueChange).toHaveBeenCalledWith(100); + }); + + it('does not respond to keyboard navigation when disabled', async () => { + const onValueChange = jest.fn(); + + await render( + + ); + + const slider = screen.getByRole('slider'); + await fireEvent(slider, 'keyDown', { + key: 'ArrowRight', + preventDefault: jest.fn(), + }); + expect(onValueChange).not.toHaveBeenCalled(); + }); +}); + +describe('Slider accessibility actions (screen reader VoiceOver/TalkBack)', () => { + it('increments and decrements value on accessibilityAction', async () => { + const onValueChange = jest.fn(); + const onSlidingComplete = jest.fn(); + + await render( + + ); + + const slider = screen.getByRole('slider'); + + await fireEvent(slider, 'accessibilityAction', { + nativeEvent: { actionName: 'increment' }, + }); + expect(onValueChange).toHaveBeenCalledWith(60); + expect(onSlidingComplete).toHaveBeenCalledWith(60); + + await fireEvent(slider, 'accessibilityAction', { + nativeEvent: { actionName: 'decrement' }, + }); + expect(onValueChange).toHaveBeenCalledWith(50); + }); +}); + +describe('RangeSlider interaction (keyboard navigation & accessibility actions)', () => { + const originalOS = Platform.OS; + + beforeEach(() => { + Platform.OS = 'web'; + }); + + afterEach(() => { + Platform.OS = originalOS; + }); + + it('navigates start and end thumbs with ArrowRight / ArrowLeft respecting minSeparation', async () => { + const onValueChange = jest.fn(); + const onSlidingComplete = jest.fn(); + + await render( + + ); + + const startThumb = screen.getByLabelText('Min'); + const endThumb = screen.getByLabelText('Max'); + + // Move start thumb right + await fireEvent(startThumb, 'keyDown', { + key: 'ArrowRight', + preventDefault: jest.fn(), + }); + expect(onValueChange).toHaveBeenCalledWith([35, 70]); + expect(onSlidingComplete).toHaveBeenCalledWith([35, 70]); + + // Move end thumb left + await fireEvent(endThumb, 'keyDown', { + key: 'ArrowLeft', + preventDefault: jest.fn(), + }); + expect(onValueChange).toHaveBeenCalledWith([35, 65]); + + // Move start thumb to End (should clamp to endThumb - minSeparation = 65 - 10 = 55) + await fireEvent(startThumb, 'keyDown', { + key: 'End', + preventDefault: jest.fn(), + }); + expect(onValueChange).toHaveBeenCalledWith([55, 65]); + }); + + it('adjusts start and end thumbs via accessibilityAction', async () => { + const onValueChange = jest.fn(); + + await render( + + ); + + const startThumb = screen.getByLabelText('Min'); + const endThumb = screen.getByLabelText('Max'); + + await fireEvent(startThumb, 'accessibilityAction', { + nativeEvent: { actionName: 'increment' }, + }); + expect(onValueChange).toHaveBeenCalledWith([30, 80]); + + await fireEvent(endThumb, 'accessibilityAction', { + nativeEvent: { actionName: 'decrement' }, + }); + expect(onValueChange).toHaveBeenCalledWith([30, 70]); + }); +}); 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..2372a9c6f2 --- /dev/null +++ b/src/components/__tests__/__snapshots__/Slider.test.tsx.snap @@ -0,0 +1,1183 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`RangeSlider render & accessibility renders disabled range slider 1`] = ` + + + + + + + + 20 + + + + + + + + + 80 + + + + + +`; + +exports[`RangeSlider render & accessibility renders range slider 1`] = ` + + + + + + + + 20 + + + + + + + + + 80 + + + + + +`; + +exports[`Slider render renders centered slider 1`] = ` + + + +`; + +exports[`Slider render renders disabled slider 1`] = ` + + + +`; + +exports[`Slider render renders discrete slider with tick marks 1`] = ` + + + +`; + +exports[`Slider render renders standard continuous slider 1`] = ` + + + +`; + +exports[`Slider render renders with start and end icons 1`] = ` + + + + volume-low + + + + + + volume-high + + + +`; + +exports[`Slider render renders with value indicator visible 1`] = ` + + + +`; diff --git a/src/index.tsx b/src/index.tsx index 1807d2f249..5998ed3318 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -44,6 +44,7 @@ export { default as Searchbar } from './components/Searchbar'; export { default as Snackbar } from './components/Snackbar'; export { default as Surface } from './components/Surface'; export { default as Switch } from './components/Switch/Switch'; +export { default as Slider, RangeSlider } from './components/Slider'; export { default as Appbar } from './components/Appbar'; export { default as TouchableRipple } from './components/TouchableRipple/TouchableRipple'; export { default as TextInput } from './components/TextInput'; @@ -127,6 +128,7 @@ export type { Props as SearchbarProps } from './components/Searchbar'; 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'; +export type { SliderProps, RangeSliderProps } from './components/Slider'; export type { TextInputProps, TextInputRenderProps,