From 7caab6dfd376b08126373fc531f5032dc2e993e2 Mon Sep 17 00:00:00 2001 From: Adrian Cotfas Date: Mon, 15 Jun 2026 17:06:29 +0300 Subject: [PATCH 01/10] feat: add slider component --- example/src/ExampleList.tsx | 2 + example/src/Examples/SliderExample.tsx | 240 ++ src/components/Slider/Slider.tsx | 965 +++++++ src/components/Slider/index.ts | 22 + src/components/Slider/tokens.ts | 89 + src/components/Slider/utils.ts | 92 + src/components/__tests__/Slider.test.tsx | 205 ++ .../__snapshots__/Slider.test.tsx.snap | 2380 +++++++++++++++++ src/index.tsx | 2 + 9 files changed, 3997 insertions(+) create mode 100644 example/src/Examples/SliderExample.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/example/src/ExampleList.tsx b/example/src/ExampleList.tsx index 53132e299f..b64d1de7db 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 = { 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'; From ab8087b3028d8dbc12e55b526ab195a64630d407 Mon Sep 17 00:00:00 2001 From: Doberjohn Date: Thu, 30 Jul 2026 13:18:18 +0300 Subject: [PATCH 02/10] Fixed slider spec inconsistencies --- src/components/Slider/Slider.tsx | 15 +++---- src/components/Slider/tokens.ts | 11 +++-- .../__snapshots__/Slider.test.tsx.snap | 42 +++++++++---------- 3 files changed, 36 insertions(+), 32 deletions(-) diff --git a/src/components/Slider/Slider.tsx b/src/components/Slider/Slider.tsx index fb2a7b1d9f..7d89a75e1b 100644 --- a/src/components/Slider/Slider.tsx +++ b/src/components/Slider/Slider.tsx @@ -23,7 +23,8 @@ import { SIZE_SPECS, STOP_SIZE, VALUE_INDICATOR_BOTTOM_SPACE, - VALUE_INDICATOR_SIZE, + VALUE_INDICATOR_HEIGHT, + VALUE_INDICATOR_WIDTH, SliderTokens, type SliderSize, } from './tokens'; @@ -183,7 +184,7 @@ const Slider = (props: Props) => { // Extra space above the track for the value indicator bubble (horizontal only) const verticalOffset = showValueIndicator && !isVertical - ? VALUE_INDICATOR_SIZE + VALUE_INDICATOR_BOTTOM_SPACE + ? VALUE_INDICATOR_HEIGHT + VALUE_INDICATOR_BOTTOM_SPACE : 0; const trackLengthSV = useSharedValue(0); @@ -422,8 +423,8 @@ const Slider = (props: Props) => { const pos = (1 - endFractionSV.value) * len; return { opacity: valueIndicatorAlphaSV.value, - top: pos - VALUE_INDICATOR_SIZE / 2, - left: -(VALUE_INDICATOR_SIZE + VALUE_INDICATOR_BOTTOM_SPACE), + top: pos - VALUE_INDICATOR_HEIGHT / 2, + left: -(VALUE_INDICATOR_WIDTH + VALUE_INDICATOR_BOTTOM_SPACE), right: undefined, bottom: undefined, transform: [], @@ -432,7 +433,7 @@ const Slider = (props: Props) => { return { opacity: valueIndicatorAlphaSV.value, transform: [ - { translateX: endFractionSV.value * len - VALUE_INDICATOR_SIZE / 2 }, + { translateX: endFractionSV.value * len - VALUE_INDICATOR_WIDTH / 2 }, ], }; }); @@ -954,8 +955,8 @@ const styles = StyleSheet.create({ valueIndicator: { position: 'absolute', top: 0, - width: VALUE_INDICATOR_SIZE, - height: VALUE_INDICATOR_SIZE, + width: VALUE_INDICATOR_WIDTH, + height: VALUE_INDICATOR_HEIGHT, borderRadius: cornerFull, justifyContent: 'center', alignItems: 'center', diff --git a/src/components/Slider/tokens.ts b/src/components/Slider/tokens.ts index f101ff7633..146b0c842f 100644 --- a/src/components/Slider/tokens.ts +++ b/src/components/Slider/tokens.ts @@ -36,11 +36,11 @@ export const SIZE_SPECS: Record = { }, m: { trackThickness: 40, - handleHeight: 44, + handleHeight: 52, handleWidth: 4, handlePressWidth: 2, activeLeadingRadius: 12, - inactiveTrailingRadius: 6, + inactiveTrailingRadius: 12, iconSize: 24, iconPadding: 6, }, @@ -67,9 +67,12 @@ export const SIZE_SPECS: Record = { } as const; export const STOP_SIZE = 4; -export const BETWEEN_HANDLE_SPACE = 4; +/** md.comp.slider active handle leading/trailing space — the gap either side of the handle. */ +export const BETWEEN_HANDLE_SPACE = 6; export const INNER_CORNER_RADIUS = 2; -export const VALUE_INDICATOR_SIZE = 44; +/** Label container: 48dp wide by 44dp tall, so not a square. */ +export const VALUE_INDICATOR_WIDTH = 48; +export const VALUE_INDICATOR_HEIGHT = 44; export const VALUE_INDICATOR_BOTTOM_SPACE = 12; export const DISABLED_CONTENT_OPACITY = 0.38; diff --git a/src/components/__tests__/__snapshots__/Slider.test.tsx.snap b/src/components/__tests__/__snapshots__/Slider.test.tsx.snap index e4fd18a3e7..9197d6e98f 100644 --- a/src/components/__tests__/__snapshots__/Slider.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/Slider.test.tsx.snap @@ -143,7 +143,7 @@ exports[`Slider renders Slider.Centered shorthand 1`] = ` { "bottom": 0, "height": undefined, - "left": 6, + "left": 8, "right": undefined, "top": 0, "width": 0, @@ -298,7 +298,7 @@ exports[`Slider renders Slider.Range shorthand 1`] = ` { "bottom": 0, "height": undefined, - "left": 6, + "left": 8, "right": undefined, "top": 0, "width": 0, @@ -325,7 +325,7 @@ exports[`Slider renders Slider.Range shorthand 1`] = ` { "bottom": 0, "height": undefined, - "left": 6, + "left": 8, "right": undefined, "top": 0, "width": 0, @@ -503,7 +503,7 @@ exports[`Slider renders centered variant 1`] = ` { "bottom": 0, "height": undefined, - "left": 6, + "left": 8, "right": undefined, "top": 0, "width": 0, @@ -658,7 +658,7 @@ exports[`Slider renders disabled standard 1`] = ` { "bottom": 0, "height": undefined, - "left": 6, + "left": 8, "right": undefined, "top": 0, "width": 0, @@ -813,7 +813,7 @@ exports[`Slider renders each size 1`] = ` { "bottom": 0, "height": undefined, - "left": 6, + "left": 8, "right": undefined, "top": 0, "width": 0, @@ -968,7 +968,7 @@ exports[`Slider renders each size 2`] = ` { "bottom": 0, "height": undefined, - "left": 6, + "left": 8, "right": undefined, "top": 0, "width": 0, @@ -1039,7 +1039,7 @@ exports[`Slider renders each size 3`] = ` style={ [ { - "height": 44, + "height": 52, "overflow": "visible", }, undefined, @@ -1104,7 +1104,7 @@ exports[`Slider renders each size 3`] = ` "left": 0, "position": "absolute", "right": 0, - "top": 2, + "top": 6, } } > @@ -1116,14 +1116,14 @@ exports[`Slider renders each size 3`] = ` }, { "borderBottomLeftRadius": 2, - "borderBottomRightRadius": 6, + "borderBottomRightRadius": 12, "borderTopLeftRadius": 2, - "borderTopRightRadius": 6, + "borderTopRightRadius": 12, }, { "bottom": 0, "height": undefined, - "left": 6, + "left": 8, "right": undefined, "top": 0, "width": 0, @@ -1278,7 +1278,7 @@ exports[`Slider renders each size 4`] = ` { "bottom": 0, "height": undefined, - "left": 6, + "left": 8, "right": undefined, "top": 0, "width": 0, @@ -1433,7 +1433,7 @@ exports[`Slider renders each size 5`] = ` { "bottom": 0, "height": undefined, - "left": 6, + "left": 8, "right": undefined, "top": 0, "width": 0, @@ -1615,7 +1615,7 @@ exports[`Slider renders range variant 1`] = ` { "bottom": 0, "height": undefined, - "left": 6, + "left": 8, "right": undefined, "top": 0, "width": 0, @@ -1642,7 +1642,7 @@ exports[`Slider renders range variant 1`] = ` { "bottom": 0, "height": undefined, - "left": 6, + "left": 8, "right": undefined, "top": 0, "width": 0, @@ -1793,7 +1793,7 @@ exports[`Slider renders standard slider 1`] = ` { "bottom": 0, "height": undefined, - "left": 6, + "left": 8, "right": undefined, "top": 0, "width": 0, @@ -2103,7 +2103,7 @@ exports[`Slider renders with stop indicators 1`] = ` { "bottom": 0, "height": undefined, - "left": 6, + "left": 8, "right": undefined, "top": 0, "width": 0, @@ -2258,7 +2258,7 @@ exports[`Slider renders with value indicator 1`] = ` { "bottom": 0, "height": undefined, - "left": 6, + "left": 8, "right": undefined, "top": 0, "width": 0, @@ -2332,13 +2332,13 @@ exports[`Slider renders with value indicator 1`] = ` "justifyContent": "center", "position": "absolute", "top": 0, - "width": 44, + "width": 48, }, { "opacity": 0, "transform": [ { - "translateX": -22, + "translateX": -24, }, ], }, From 8badc4f3c7a862f638e8537ddd94d253f44496fd Mon Sep 17 00:00:00 2001 From: Doberjohn Date: Thu, 30 Jul 2026 15:03:02 +0300 Subject: [PATCH 03/10] fixed stale closure reads in Slider gesture handlers --- src/components/Slider/Slider.tsx | 102 +++++++++++++++-------- src/components/__tests__/Slider.test.tsx | 82 ++++++++++++++++++ 2 files changed, 148 insertions(+), 36 deletions(-) diff --git a/src/components/Slider/Slider.tsx b/src/components/Slider/Slider.tsx index 7d89a75e1b..a165c89a87 100644 --- a/src/components/Slider/Slider.tsx +++ b/src/components/Slider/Slider.tsx @@ -439,6 +439,10 @@ const Slider = (props: Props) => { }); // Gesture handling + // + // The PanResponder below is created once, so its handler closures capture the + // first render's scope forever. Anything they read that can change between + // renders has to come through this ref instead. const valuesRef = React.useRef({ min, max, @@ -449,6 +453,10 @@ const Slider = (props: Props) => { variant, isRTL, isVertical, + disabled, + showValueIndicator, + spec, + timingConfig, }); valuesRef.current = { min, @@ -460,15 +468,24 @@ const Slider = (props: Props) => { variant, isRTL, isVertical, + disabled, + showValueIndicator, + spec, + timingConfig, }; + // Callbacks are read through their own ref so that a consumer passing an + // inline arrow gets the current one rather than the first render's. + const propsRef = React.useRef(props); + propsRef.current = props; + const grantTouchRef = React.useRef(0); const activeHandleRef = React.useRef<'start' | 'end'>('end'); const panResponder = React.useRef( PanResponder.create({ - onStartShouldSetPanResponder: () => !disabled, - onMoveShouldSetPanResponder: () => !disabled, + onStartShouldSetPanResponder: () => !valuesRef.current.disabled, + onMoveShouldSetPanResponder: () => !valuesRef.current.disabled, onPanResponderGrant: (evt) => { const { @@ -480,6 +497,9 @@ const Slider = (props: Props) => { startValue: sv, endValue: ev, variant: vt, + showValueIndicator: svi, + spec: sp, + timingConfig: tc, } = valuesRef.current; const touchPx = iv ? evt.nativeEvent.locationY @@ -498,27 +518,20 @@ const Slider = (props: Props) => { } if (activeHandleRef.current === 'start') { - startHandleWidthSV.value = withTiming( - spec.handlePressWidth, - timingConfig - ); + startHandleWidthSV.value = withTiming(sp.handlePressWidth, tc); } else { - endHandleWidthSV.value = withTiming( - spec.handlePressWidth, - timingConfig - ); + endHandleWidthSV.value = withTiming(sp.handlePressWidth, tc); } - if (showValueIndicator) { - valueIndicatorAlphaSV.value = withTiming(1, timingConfig); + if (svi) { + valueIndicatorAlphaSV.value = withTiming(1, tc); } - if (props.onSlidingStart) { - if (isRange) { - (props as RangeProps).onSlidingStart?.([sv, ev]); - } else { - (props as StandardProps | CenteredProps).onSlidingStart?.(ev); - } + const p = propsRef.current; + if (vt === 'range') { + (p as RangeProps).onSlidingStart?.([sv, ev]); + } else { + (p as StandardProps | CenteredProps).onSlidingStart?.(ev); } }, @@ -533,8 +546,12 @@ const Slider = (props: Props) => { variant: vt, isVertical: iv, isRTL: rtl, + disabled: dis, } = valuesRef.current; + // `disabled` can flip mid-drag; the responder is already active by then. + if (dis) return; + const delta = iv ? gestureState.dy : gestureState.dx; const touchPx = grantTouchRef.current + delta; const fraction = positionToFraction(touchPx, tl, rtl, iv); @@ -543,58 +560,71 @@ const Slider = (props: Props) => { const displayFraction = st > 0 ? valueToFraction(snapped, mn, mx) : fraction; + const p = propsRef.current; 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]); + (p 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]); + (p as RangeProps).onValueChange?.([sv, clamped]); } } else { endFractionSV.value = displayFraction; setEndValue(snapped); setIndicatorDisplayValue(snapped); - (props as StandardProps | CenteredProps).onValueChange?.(snapped); + (p as StandardProps | CenteredProps).onValueChange?.(snapped); } }, onPanResponderRelease: () => { - const { endValue: ev, startValue: sv } = valuesRef.current; + const { + endValue: ev, + startValue: sv, + variant: vt, + showValueIndicator: svi, + spec: sp, + timingConfig: tc, + } = valuesRef.current; if (activeHandleRef.current === 'start') { - startHandleWidthSV.value = withTiming(spec.handleWidth, timingConfig); + startHandleWidthSV.value = withTiming(sp.handleWidth, tc); } else { - endHandleWidthSV.value = withTiming(spec.handleWidth, timingConfig); + endHandleWidthSV.value = withTiming(sp.handleWidth, tc); } - if (showValueIndicator) { - valueIndicatorAlphaSV.value = withTiming(0, timingConfig); + if (svi) { + valueIndicatorAlphaSV.value = withTiming(0, tc); } - if (props.onSlidingComplete) { - if (isRange) { - (props as RangeProps).onSlidingComplete?.([sv, ev]); - } else { - (props as StandardProps | CenteredProps).onSlidingComplete?.(ev); - } + const p = propsRef.current; + if (vt === 'range') { + (p as RangeProps).onSlidingComplete?.([sv, ev]); + } else { + (p as StandardProps | CenteredProps).onSlidingComplete?.(ev); } }, onPanResponderTerminate: () => { + const { + showValueIndicator: svi, + spec: sp, + timingConfig: tc, + } = valuesRef.current; + if (activeHandleRef.current === 'start') { - startHandleWidthSV.value = withTiming(spec.handleWidth, timingConfig); + startHandleWidthSV.value = withTiming(sp.handleWidth, tc); } else { - endHandleWidthSV.value = withTiming(spec.handleWidth, timingConfig); + endHandleWidthSV.value = withTiming(sp.handleWidth, tc); } - if (showValueIndicator) { - valueIndicatorAlphaSV.value = withTiming(0, timingConfig); + if (svi) { + valueIndicatorAlphaSV.value = withTiming(0, tc); } }, }) diff --git a/src/components/__tests__/Slider.test.tsx b/src/components/__tests__/Slider.test.tsx index 0abed3a919..d4421ae07a 100644 --- a/src/components/__tests__/Slider.test.tsx +++ b/src/components/__tests__/Slider.test.tsx @@ -203,3 +203,85 @@ describe('Slider accessibility', () => { }); }); }); + +// The PanResponder is built once and never rebuilt, so `disabled` has to be +// read through a ref. These assert it is, in both directions. +describe('Slider disabled', () => { + it('stops accepting gestures when disabled is toggled on after mount', () => { + const { getByRole, update } = render(); + expect(getByRole('adjustable').props.onStartShouldSetResponder()).toBe( + true + ); + + update(); + expect(getByRole('adjustable').props.onStartShouldSetResponder()).toBe( + false + ); + }); + + it('accepts gestures again when disabled is toggled back off', () => { + const { getByRole, update } = render(); + expect(getByRole('adjustable').props.onStartShouldSetResponder()).toBe( + false + ); + + update(); + expect(getByRole('adjustable').props.onStartShouldSetResponder()).toBe( + true + ); + }); +}); + +// A synthetic touch, shaped the way PanResponder's internals expect: it reads +// the centroid out of `touchHistory.touchBank` before delegating to our handler. +const touchTrack = (x: number) => ({ + touchActive: true, + startPageX: x, + startPageY: 0, + startTimeStamp: 0, + currentPageX: x, + currentPageY: 0, + currentTimeStamp: 0, + previousPageX: x, + previousPageY: 0, + previousTimeStamp: 0, +}); + +const grantEvent = (x: number) => ({ + nativeEvent: { + locationX: x, + locationY: 0, + identifier: 0, + pageX: x, + pageY: 0, + timestamp: 0, + target: 1, + touches: [], + changedTouches: [], + }, + touchHistory: { + touchBank: [touchTrack(x)], + numberActiveTouches: 1, + indexOfSingleActiveTouch: 0, + mostRecentTimeStamp: 0, + }, +}); + +describe('Slider gesture callbacks', () => { + it('calls the callback from the latest render, not the first', () => { + const first = jest.fn(); + const second = jest.fn(); + + const { getByRole, update } = render( + + ); + getByRole('adjustable').props.onResponderGrant(grantEvent(10)); + expect(first).toHaveBeenCalledTimes(1); + + update(); + getByRole('adjustable').props.onResponderGrant(grantEvent(10)); + + expect(second).toHaveBeenCalledTimes(1); + expect(first).toHaveBeenCalledTimes(1); + }); +}); From 8a2a69233e7ed2b124a8f5c72eca4ef8071a286f Mon Sep 17 00:00:00 2001 From: Doberjohn Date: Thu, 30 Jul 2026 15:39:27 +0300 Subject: [PATCH 04/10] fixed handles that deadlocked at the track ends --- src/components/Slider/Slider.tsx | 54 ++++++--- src/components/Slider/utils.ts | 26 ++++ src/components/__tests__/Slider.test.tsx | 144 +++++++++++++++++------ 3 files changed, 177 insertions(+), 47 deletions(-) diff --git a/src/components/Slider/Slider.tsx b/src/components/Slider/Slider.tsx index a165c89a87..b7474f091a 100644 --- a/src/components/Slider/Slider.tsx +++ b/src/components/Slider/Slider.tsx @@ -30,8 +30,9 @@ import { } from './tokens'; import { fractionToValue, - nearestHandle, + HANDLE_DIRECTION_THRESHOLD, positionToFraction, + rangeHandleForTouch, stopFractions, valueToFraction, } from './utils'; @@ -480,7 +481,9 @@ const Slider = (props: Props) => { propsRef.current = props; const grantTouchRef = React.useRef(0); - const activeHandleRef = React.useRef<'start' | 'end'>('end'); + // 'pending' means the handles overlapped at touch-down, so which one moves is + // decided by the first deliberate drag direction instead. + const activeHandleRef = React.useRef<'start' | 'end' | 'pending'>('end'); const panResponder = React.useRef( PanResponder.create({ @@ -508,7 +511,7 @@ const Slider = (props: Props) => { if (vt === 'range') { const f = positionToFraction(touchPx, tl, rtl, iv); - activeHandleRef.current = nearestHandle( + activeHandleRef.current = rangeHandleForTouch( f, valueToFraction(sv, mn, mx), valueToFraction(ev, mn, mx) @@ -517,9 +520,10 @@ const Slider = (props: Props) => { activeHandleRef.current = 'end'; } + // Left 'pending' the choice isn't made yet, so nothing is pressed yet. if (activeHandleRef.current === 'start') { startHandleWidthSV.value = withTiming(sp.handlePressWidth, tc); - } else { + } else if (activeHandleRef.current === 'end') { endHandleWidthSV.value = withTiming(sp.handlePressWidth, tc); } @@ -547,12 +551,37 @@ const Slider = (props: Props) => { isVertical: iv, isRTL: rtl, disabled: dis, + spec: sp, + timingConfig: tc, } = valuesRef.current; // `disabled` can flip mid-drag; the responder is already active by then. if (dis) return; const delta = iv ? gestureState.dy : gestureState.dx; + + if (activeHandleRef.current === 'pending') { + if (Math.abs(delta) < HANDLE_DIRECTION_THRESHOLD) return; + // Read the direction through the same mapping that positions the + // handle, so the vertical and RTL inversions cannot drift apart from + // it. Equal fractions mean the drag is pushing past an end stop, so + // 'end' is chosen and its clamp makes the move a no-op. + const from = positionToFraction(grantTouchRef.current, tl, rtl, iv); + const to = positionToFraction( + grantTouchRef.current + delta, + tl, + rtl, + iv + ); + activeHandleRef.current = to >= from ? 'end' : 'start'; + + if (activeHandleRef.current === 'start') { + startHandleWidthSV.value = withTiming(sp.handlePressWidth, tc); + } else { + endHandleWidthSV.value = withTiming(sp.handlePressWidth, tc); + } + } + const touchPx = grantTouchRef.current + delta; const fraction = positionToFraction(touchPx, tl, rtl, iv); const snapped = fractionToValue(fraction, mn, mx, st); @@ -593,11 +622,10 @@ const Slider = (props: Props) => { timingConfig: tc, } = valuesRef.current; - if (activeHandleRef.current === 'start') { - startHandleWidthSV.value = withTiming(sp.handleWidth, tc); - } else { - endHandleWidthSV.value = withTiming(sp.handleWidth, tc); - } + // Reset both: the gesture may have ended while still 'pending', and the + // handle that was never pressed is already at its resting width. + startHandleWidthSV.value = withTiming(sp.handleWidth, tc); + endHandleWidthSV.value = withTiming(sp.handleWidth, tc); if (svi) { valueIndicatorAlphaSV.value = withTiming(0, tc); @@ -618,11 +646,9 @@ const Slider = (props: Props) => { timingConfig: tc, } = valuesRef.current; - if (activeHandleRef.current === 'start') { - startHandleWidthSV.value = withTiming(sp.handleWidth, tc); - } else { - endHandleWidthSV.value = withTiming(sp.handleWidth, tc); - } + startHandleWidthSV.value = withTiming(sp.handleWidth, tc); + endHandleWidthSV.value = withTiming(sp.handleWidth, tc); + if (svi) { valueIndicatorAlphaSV.value = withTiming(0, tc); } diff --git a/src/components/Slider/utils.ts b/src/components/Slider/utils.ts index 41769af863..c9ae94a27d 100644 --- a/src/components/Slider/utils.ts +++ b/src/components/Slider/utils.ts @@ -90,3 +90,29 @@ export function nearestHandle( const distEnd = Math.abs(touchFraction - endFraction); return distStart < distEnd ? 'start' : 'end'; } + +/** Handles closer together than this count as sitting on the same value. */ +const HANDLE_OVERLAP_EPSILON = 1e-4; + +/** Pixels a drag must travel before its direction is taken as deliberate. */ +export const HANDLE_DIRECTION_THRESHOLD = 1; + +/** + * Picks the handle a range gesture should drag. + * + * While the handles are apart it is simply the nearest one. Once they sit on the + * same value there is no nearest handle — every touch is equidistant — and + * guessing deadlocks the gesture: with both at `max`, the end handle is floored + * by the start handle and cannot move, while the start handle can never be + * picked. So overlap defers to 'pending', and the drag direction decides. + */ +export function rangeHandleForTouch( + touchFraction: number, + startFraction: number, + endFraction: number +): 'start' | 'end' | 'pending' { + if (Math.abs(endFraction - startFraction) <= HANDLE_OVERLAP_EPSILON) { + return 'pending'; + } + return nearestHandle(touchFraction, startFraction, endFraction); +} diff --git a/src/components/__tests__/Slider.test.tsx b/src/components/__tests__/Slider.test.tsx index d4421ae07a..9757c9c88e 100644 --- a/src/components/__tests__/Slider.test.tsx +++ b/src/components/__tests__/Slider.test.tsx @@ -1,12 +1,13 @@ import * as React from 'react'; -import { render } from '../../test-utils'; +import { act, fireEvent, render } from '../../test-utils'; import Slider from '../Slider'; import { activeSegment, fractionToValue, nearestHandle, positionToFraction, + rangeHandleForTouch, snapToStep, stopFractions, valueToFraction, @@ -136,6 +137,21 @@ describe('nearestHandle', () => { }); }); +describe('rangeHandleForTouch', () => { + it('picks the nearest handle while they are apart', () => { + expect(rangeHandleForTouch(0.2, 0.1, 0.9)).toBe('start'); + expect(rangeHandleForTouch(0.8, 0.1, 0.9)).toBe('end'); + }); + + // Overlapping handles make every touch equidistant, so nearest-handle would + // always answer 'end' — and at max that handle is the one that cannot move. + it('defers to the drag direction when the handles overlap', () => { + expect(rangeHandleForTouch(0.5, 1, 1)).toBe('pending'); + expect(rangeHandleForTouch(0, 0, 0)).toBe('pending'); + expect(rangeHandleForTouch(0.5, 0.5, 0.5)).toBe('pending'); + }); +}); + // ---- Component render tests ---- describe('Slider renders', () => { @@ -232,39 +248,101 @@ describe('Slider disabled', () => { }); }); -// A synthetic touch, shaped the way PanResponder's internals expect: it reads -// the centroid out of `touchHistory.touchBank` before delegating to our handler. -const touchTrack = (x: number) => ({ - touchActive: true, - startPageX: x, - startPageY: 0, - startTimeStamp: 0, - currentPageX: x, - currentPageY: 0, - currentTimeStamp: 0, - previousPageX: x, - previousPageY: 0, - previousTimeStamp: 0, -}); +// Synthetic touches, shaped the way PanResponder's internals expect: it reads +// the centroid out of `touchHistory.touchBank`, and derives dx from the gap +// between a touch's previous and current position, for touches whose timestamp +// is newer than the last move it accounted for. +const touchEvent = (fromX: number, toX: number) => { + const moved = toX !== fromX; + return { + nativeEvent: { + locationX: fromX, + locationY: 0, + identifier: 0, + pageX: fromX, + pageY: 0, + timestamp: 0, + target: 1, + touches: [], + changedTouches: [], + }, + touchHistory: { + touchBank: [ + { + touchActive: true, + startPageX: fromX, + startPageY: 0, + startTimeStamp: 0, + currentPageX: toX, + currentPageY: 0, + currentTimeStamp: moved ? 1 : 0, + previousPageX: fromX, + previousPageY: 0, + previousTimeStamp: 0, + }, + ], + numberActiveTouches: 1, + indexOfSingleActiveTouch: 0, + mostRecentTimeStamp: moved ? 1 : 0, + }, + }; +}; + +const grantEvent = (x: number) => touchEvent(x, x); + +const TRACK_WIDTH = 300; + +// Nothing lays out in the test renderer, so the track measures 0 and every +// touch maps to fraction 0. Fire the layout the component is waiting for. +const layoutTrack = (r: ReturnType) => { + const nodes = r.container.findAll( + (n) => typeof n.props.onLayout === 'function' + ); + fireEvent(nodes[nodes.length - 1], 'layout', { + nativeEvent: { layout: { width: TRACK_WIDTH, height: 44 } }, + }); +}; + +const drag = (r: ReturnType, fromX: number, toX: number) => { + const view = r.getByRole('adjustable'); + act(() => { + view.props.onResponderGrant(grantEvent(fromX)); + view.props.onResponderMove(touchEvent(fromX, toX)); + }); +}; + +describe('Slider range gestures', () => { + const setup = (value: [number, number]) => { + const onValueChange = jest.fn(); + const r = render( + + ); + layoutTrack(r); + return { r, onValueChange }; + }; + + it('frees the start handle when both are pinned at max', () => { + const { r, onValueChange } = setup([100, 100]); + drag(r, 150, 90); + expect(onValueChange).toHaveBeenCalledWith([30, 100]); + }); -const grantEvent = (x: number) => ({ - nativeEvent: { - locationX: x, - locationY: 0, - identifier: 0, - pageX: x, - pageY: 0, - timestamp: 0, - target: 1, - touches: [], - changedTouches: [], - }, - touchHistory: { - touchBank: [touchTrack(x)], - numberActiveTouches: 1, - indexOfSingleActiveTouch: 0, - mostRecentTimeStamp: 0, - }, + it('frees the end handle when both are pinned at min', () => { + const { r, onValueChange } = setup([0, 0]); + drag(r, 150, 210); + expect(onValueChange).toHaveBeenCalledWith([0, 70]); + }); + + it('still drags the nearest handle when they are apart', () => { + const { r, onValueChange } = setup([20, 80]); + drag(r, 240, 210); + expect(onValueChange).toHaveBeenCalledWith([20, 70]); + }); }); describe('Slider gesture callbacks', () => { From 722c15934483a7bc4eefb7cc535fe67cec8564ae Mon Sep 17 00:00:00 2001 From: Doberjohn Date: Fri, 31 Jul 2026 20:12:33 +0300 Subject: [PATCH 05/10] aligned slider handle travel with the stop indicators --- src/components/Slider/Slider.tsx | 170 ++++++++++++------ src/components/Slider/utils.ts | 24 ++- src/components/__tests__/Slider.test.tsx | 36 +++- .../__snapshots__/Slider.test.tsx.snap | 76 ++++---- 4 files changed, 197 insertions(+), 109 deletions(-) diff --git a/src/components/Slider/Slider.tsx b/src/components/Slider/Slider.tsx index b7474f091a..7a2619a2ce 100644 --- a/src/components/Slider/Slider.tsx +++ b/src/components/Slider/Slider.tsx @@ -215,11 +215,29 @@ const Slider = (props: Props) => { // gap = half handle width + between-handle space const trackHandleOffset = spec.handleWidth / 2 + BETWEEN_HANDLE_SPACE; + // Where the handles travel: fraction 0 sits `handleInsetStart` px in from the + // leading edge of the track, fraction 1 sits `handleInsetEnd` px back from the + // trailing edge. Every handle, track segment, indicator, stop and touch + // position is derived from these two numbers, so they are the only place to + // change it. + // + // The corner radii put each end of the range at the centre of its rounded cap, + // which is where the end stops are drawn — so a handle at min or max lands on + // its own stop instead of overshooting it by the radius. + const handleInsetStart = spec.activeLeadingRadius; + const handleInsetEnd = spec.inactiveTrailingRadius; + const usableTrackLength = Math.max( + 0, + trackLength - handleInsetStart - handleInsetEnd + ); + // 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 origin = handleInsetStart; + const usable = Math.max(0, len - handleInsetStart - handleInsetEnd); const gap = trackHandleOffset; if (isVertical) { @@ -229,19 +247,20 @@ const Slider = (props: Props) => { 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); + bottom = origin + lo * usable + gap; + height = Math.max(0, (hi - lo) * usable - 2 * gap); } else if (variant === 'centered') { if (vf >= 0.5) { - bottom = 0.5 * len; - height = Math.max(0, (vf - 0.5) * len - gap); + bottom = origin + 0.5 * usable; + height = Math.max(0, (vf - 0.5) * usable - gap); } else { - bottom = vf * len + gap; - height = Math.max(0, (0.5 - vf) * len - gap); + bottom = origin + vf * usable + gap; + height = Math.max(0, (0.5 - vf) * usable - gap); } } else { + // Leading edge is the track's own end, not a handle position. bottom = 0; - height = Math.max(0, vf * len - gap); + height = Math.max(0, origin + vf * usable - gap); } return { @@ -260,19 +279,20 @@ const Slider = (props: Props) => { 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); + left = origin + lo * usable + gap; + width = Math.max(0, (hi - lo) * usable - 2 * gap); } else if (variant === 'centered') { if (vf >= 0.5) { - left = 0.5 * len; - width = Math.max(0, (vf - 0.5) * len - gap); + left = origin + 0.5 * usable; + width = Math.max(0, (vf - 0.5) * usable - gap); } else { - left = vf * len + gap; - width = Math.max(0, (0.5 - vf) * len - gap); + left = origin + vf * usable + gap; + width = Math.max(0, (0.5 - vf) * usable - gap); } } else { + // Leading edge is the track's own end, not a handle position. left = 0; - width = Math.max(0, vf * len - gap); + width = Math.max(0, origin + vf * usable - gap); } return { @@ -290,20 +310,24 @@ const Slider = (props: Props) => { const vf = endFractionSV.value; const sf = startFractionSV.value; const len = trackLengthSV.value; + const origin = handleInsetStart; + const usable = Math.max(0, len - handleInsetStart - handleInsetEnd); const gap = trackHandleOffset; + // This segment always ends at the track's own trailing edge, so its extent + // is measured back from `len` rather than from fraction 1. if (isVertical) { let height: number; if (variant === 'range') { const hi = Math.max(sf, vf); - height = Math.max(0, (1 - hi) * len - gap); + height = Math.max(0, len - (origin + hi * usable) - gap); } else if (variant === 'centered') { height = vf >= 0.5 - ? Math.max(0, (1 - vf) * len - gap) - : Math.max(0, 0.5 * len); + ? Math.max(0, len - (origin + vf * usable) - gap) + : Math.max(0, len - (origin + 0.5 * usable)); } else { - height = Math.max(0, (1 - vf) * len - gap); + height = Math.max(0, len - (origin + vf * usable) - gap); } return { top: 0, @@ -319,19 +343,19 @@ const Slider = (props: Props) => { let width: number; if (variant === 'range') { const hi = Math.max(sf, vf); - left = hi * len + gap; - width = Math.max(0, (1 - hi) * len - gap); + left = origin + hi * usable + gap; + width = Math.max(0, len - left); } else if (variant === 'centered') { if (vf >= 0.5) { - left = vf * len + gap; - width = Math.max(0, (1 - vf) * len - gap); + left = origin + vf * usable + gap; + width = Math.max(0, len - left); } else { - left = 0.5 * len; - width = Math.max(0, 0.5 * len); + left = origin + 0.5 * usable; + width = Math.max(0, len - left); } } else { - left = vf * len + gap; - width = Math.max(0, (1 - vf) * len - gap); + left = origin + vf * usable + gap; + width = Math.max(0, len - left); } return { left, @@ -353,17 +377,23 @@ const Slider = (props: Props) => { const vf = endFractionSV.value; const sf = startFractionSV.value; const len = trackLengthSV.value; + const origin = handleInsetStart; + const usable = Math.max(0, len - handleInsetStart - handleInsetEnd); const gap = trackHandleOffset; + // This segment always starts at the track's own leading edge, so its extent + // is just the distance out to the handle it stops behind. if (isVertical) { let height: number; if (variant === 'range') { const lo = Math.min(sf, vf); - height = Math.max(0, lo * len - gap); + height = Math.max(0, origin + lo * usable - gap); } else { // centered height = - vf >= 0.5 ? Math.max(0, 0.5 * len) : Math.max(0, vf * len - gap); + vf >= 0.5 + ? Math.max(0, origin + 0.5 * usable) + : Math.max(0, origin + vf * usable - gap); } return { bottom: 0, @@ -378,10 +408,13 @@ const Slider = (props: Props) => { let width: number; if (variant === 'range') { const lo = Math.min(sf, vf); - width = Math.max(0, lo * len - gap); + width = Math.max(0, origin + lo * usable - gap); } else { // centered - width = vf >= 0.5 ? Math.max(0, 0.5 * len) : Math.max(0, vf * len - gap); + width = + vf >= 0.5 + ? Math.max(0, origin + 0.5 * usable) + : Math.max(0, origin + vf * usable - gap); } return { left: 0, @@ -396,35 +429,41 @@ const Slider = (props: Props) => { // End handle animated position along the track axis const endHandleAnimStyle = useAnimatedStyle(() => { const len = trackLengthSV.value; + const origin = handleInsetStart; + const usable = Math.max(0, len - handleInsetStart - handleInsetEnd); const w = endHandleWidthSV.value; + const along = origin + endFractionSV.value * usable; if (isVertical) { - const pos = (1 - endFractionSV.value) * len; - return { top: pos - w / 2, height: w }; + // Vertical runs bottom-to-top, so measure back from the far edge. + return { top: len - along - w / 2, height: w }; } - const pos = endFractionSV.value * len; - return { left: pos - w / 2, width: w }; + return { left: along - w / 2, width: w }; }); // Start handle animated position along the track axis const startHandleAnimStyle = useAnimatedStyle(() => { const len = trackLengthSV.value; + const origin = handleInsetStart; + const usable = Math.max(0, len - handleInsetStart - handleInsetEnd); const w = startHandleWidthSV.value; + const along = origin + startFractionSV.value * usable; if (isVertical) { - const pos = (1 - startFractionSV.value) * len; - return { top: pos - w / 2, height: w }; + return { top: len - along - w / 2, height: w }; } - const pos = startFractionSV.value * len; - return { left: pos - w / 2, width: w }; + return { left: along - w / 2, width: w }; }); // Value indicator animated style const valueIndicatorAnimStyle = useAnimatedStyle(() => { const len = trackLengthSV.value; + const origin = handleInsetStart; + const usable = Math.max(0, len - handleInsetStart - handleInsetEnd); + // Tracks the end handle, so it shares the handle's mapping. + const along = origin + endFractionSV.value * usable; if (isVertical) { - const pos = (1 - endFractionSV.value) * len; return { opacity: valueIndicatorAlphaSV.value, - top: pos - VALUE_INDICATOR_HEIGHT / 2, + top: len - along - VALUE_INDICATOR_HEIGHT / 2, left: -(VALUE_INDICATOR_WIDTH + VALUE_INDICATOR_BOTTOM_SPACE), right: undefined, bottom: undefined, @@ -433,9 +472,7 @@ const Slider = (props: Props) => { } return { opacity: valueIndicatorAlphaSV.value, - transform: [ - { translateX: endFractionSV.value * len - VALUE_INDICATOR_WIDTH / 2 }, - ], + transform: [{ translateX: along - VALUE_INDICATOR_WIDTH / 2 }], }; }); @@ -458,6 +495,8 @@ const Slider = (props: Props) => { showValueIndicator, spec, timingConfig, + handleInsetStart, + handleInsetEnd, }); valuesRef.current = { min, @@ -473,6 +512,8 @@ const Slider = (props: Props) => { showValueIndicator, spec, timingConfig, + handleInsetStart, + handleInsetEnd, }; // Callbacks are read through their own ref so that a consumer passing an @@ -503,6 +544,8 @@ const Slider = (props: Props) => { showValueIndicator: svi, spec: sp, timingConfig: tc, + handleInsetStart: is, + handleInsetEnd: ie, } = valuesRef.current; const touchPx = iv ? evt.nativeEvent.locationY @@ -510,7 +553,7 @@ const Slider = (props: Props) => { grantTouchRef.current = touchPx; if (vt === 'range') { - const f = positionToFraction(touchPx, tl, rtl, iv); + const f = positionToFraction(touchPx, tl, rtl, iv, is, ie); activeHandleRef.current = rangeHandleForTouch( f, valueToFraction(sv, mn, mx), @@ -553,6 +596,8 @@ const Slider = (props: Props) => { disabled: dis, spec: sp, timingConfig: tc, + handleInsetStart: is, + handleInsetEnd: ie, } = valuesRef.current; // `disabled` can flip mid-drag; the responder is already active by then. @@ -566,12 +611,21 @@ const Slider = (props: Props) => { // handle, so the vertical and RTL inversions cannot drift apart from // it. Equal fractions mean the drag is pushing past an end stop, so // 'end' is chosen and its clamp makes the move a no-op. - const from = positionToFraction(grantTouchRef.current, tl, rtl, iv); + const from = positionToFraction( + grantTouchRef.current, + tl, + rtl, + iv, + is, + ie + ); const to = positionToFraction( grantTouchRef.current + delta, tl, rtl, - iv + iv, + is, + ie ); activeHandleRef.current = to >= from ? 'end' : 'start'; @@ -583,7 +637,7 @@ const Slider = (props: Props) => { } const touchPx = grantTouchRef.current + delta; - const fraction = positionToFraction(touchPx, tl, rtl, iv); + const fraction = positionToFraction(touchPx, tl, rtl, iv, is, ie); const snapped = fractionToValue(fraction, mn, mx, st); // Snap the visual handle position for discrete mode const displayFraction = @@ -875,14 +929,10 @@ const Slider = (props: Props) => { {/* 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); + // Stops sit on the handle's own travel range, so a handle at any stop + // lands exactly on its dot. + if (usableTrackLength <= 0) return null; + const pixelCenter = handleInsetStart + f * usableTrackLength; const dotStyle: ViewStyle = isVertical ? { position: 'absolute', @@ -921,11 +971,17 @@ const Slider = (props: Props) => { isVertical ? { left: (spec.trackThickness - spec.iconSize) / 2, - bottom: activeLead * (trackLength || 0) + iconLeadingOffset, + bottom: + handleInsetStart + + activeLead * usableTrackLength + + iconLeadingOffset, } : { top: (spec.trackThickness - spec.iconSize) / 2, - left: activeLead * (trackLength || 0) + iconLeadingOffset, + left: + handleInsetStart + + activeLead * usableTrackLength + + iconLeadingOffset, }, ]} pointerEvents="none" diff --git a/src/components/Slider/utils.ts b/src/components/Slider/utils.ts index c9ae94a27d..c4d4ff86c3 100644 --- a/src/components/Slider/utils.ts +++ b/src/components/Slider/utils.ts @@ -32,19 +32,27 @@ export function fractionToValue( return snapToStep(raw, min, max, step); } +/** + * Maps a touch position along the track axis onto a value fraction. + * + * `insetStart` and `insetEnd` must match the range the handle is drawn across, + * otherwise the handle trails behind the finger by up to the inset. Distance is + * resolved in the direction values grow *before* the inset is applied, so the + * two insets stay independent rather than only working when they are equal. + */ export function positionToFraction( touchPx: number, trackLengthPx: number, isRTL: boolean, - isVertical: boolean + isVertical: boolean, + insetStart: number = 0, + insetEnd: number = 0 ): 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); + const usable = trackLengthPx - insetStart - insetEnd; + if (usable <= 0) return 0; + // Vertical: top of track = max, bottom = min. RTL horizontal: right = min. + const along = isVertical || isRTL ? trackLengthPx - touchPx : touchPx; + return clamp((along - insetStart) / usable, 0, 1); } export function stopFractions( diff --git a/src/components/__tests__/Slider.test.tsx b/src/components/__tests__/Slider.test.tsx index 9757c9c88e..a90e0070b2 100644 --- a/src/components/__tests__/Slider.test.tsx +++ b/src/components/__tests__/Slider.test.tsx @@ -83,6 +83,28 @@ describe('positionToFraction', () => { it('returns 0 when trackLengthPx is 0', () => { expect(positionToFraction(50, 0, false, false)).toBe(0); }); + + // Handles are drawn across an inset range, so touches must map onto the same + // range or the handle trails behind the finger. + it('maps onto the inset range, clamping outside it', () => { + // 300px track inset 12 either end: the usable span is 276, from 12 to 288. + expect(positionToFraction(12, 300, false, false, 12, 12)).toBe(0); + expect(positionToFraction(150, 300, false, false, 12, 12)).toBe(0.5); + expect(positionToFraction(288, 300, false, false, 12, 12)).toBe(1); + expect(positionToFraction(0, 300, false, false, 12, 12)).toBe(0); + expect(positionToFraction(300, 300, false, false, 12, 12)).toBe(1); + }); + + it('keeps the two insets independent when vertical', () => { + // Vertical runs bottom-to-top, so fraction 0 sits `insetStart` up from the + // bottom (y = 280) and fraction 1 sits `insetEnd` down from the top (y = 10). + expect(positionToFraction(280, 300, false, true, 20, 10)).toBe(0); + expect(positionToFraction(10, 300, false, true, 20, 10)).toBe(1); + }); + + it('returns 0 when the insets swallow the whole track', () => { + expect(positionToFraction(10, 20, false, false, 12, 12)).toBe(0); + }); }); describe('stopFractions', () => { @@ -326,22 +348,24 @@ describe('Slider range gestures', () => { return { r, onValueChange }; }; + // Handles travel an inset range, so on a 300px track at size `s` (8dp insets) + // the usable span is 284: fraction 0.25 is at pixel 79, 0.5 at 150, 0.75 at 221. it('frees the start handle when both are pinned at max', () => { const { r, onValueChange } = setup([100, 100]); - drag(r, 150, 90); - expect(onValueChange).toHaveBeenCalledWith([30, 100]); + drag(r, 150, 79); + expect(onValueChange).toHaveBeenCalledWith([25, 100]); }); it('frees the end handle when both are pinned at min', () => { const { r, onValueChange } = setup([0, 0]); - drag(r, 150, 210); - expect(onValueChange).toHaveBeenCalledWith([0, 70]); + drag(r, 150, 221); + expect(onValueChange).toHaveBeenCalledWith([0, 75]); }); it('still drags the nearest handle when they are apart', () => { const { r, onValueChange } = setup([20, 80]); - drag(r, 240, 210); - expect(onValueChange).toHaveBeenCalledWith([20, 70]); + drag(r, 221, 150); + expect(onValueChange).toHaveBeenCalledWith([20, 50]); }); }); diff --git a/src/components/__tests__/__snapshots__/Slider.test.tsx.snap b/src/components/__tests__/__snapshots__/Slider.test.tsx.snap index 9197d6e98f..35ced38afa 100644 --- a/src/components/__tests__/__snapshots__/Slider.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/Slider.test.tsx.snap @@ -116,7 +116,7 @@ exports[`Slider renders Slider.Centered shorthand 1`] = ` { "bottom": 0, "height": undefined, - "left": 0, + "left": 8, "right": undefined, "top": 0, "width": 0, @@ -143,7 +143,7 @@ exports[`Slider renders Slider.Centered shorthand 1`] = ` { "bottom": 0, "height": undefined, - "left": 8, + "left": 16, "right": undefined, "top": 0, "width": 0, @@ -169,7 +169,7 @@ exports[`Slider renders Slider.Centered shorthand 1`] = ` "top": 0, }, { - "left": -2, + "left": 6, "width": 4, }, { @@ -298,7 +298,7 @@ exports[`Slider renders Slider.Range shorthand 1`] = ` { "bottom": 0, "height": undefined, - "left": 8, + "left": 16, "right": undefined, "top": 0, "width": 0, @@ -325,7 +325,7 @@ exports[`Slider renders Slider.Range shorthand 1`] = ` { "bottom": 0, "height": undefined, - "left": 8, + "left": 16, "right": undefined, "top": 0, "width": 0, @@ -351,7 +351,7 @@ exports[`Slider renders Slider.Range shorthand 1`] = ` "top": 0, }, { - "left": -2, + "left": 6, "width": 4, }, { @@ -374,7 +374,7 @@ exports[`Slider renders Slider.Range shorthand 1`] = ` "top": 0, }, { - "left": -2, + "left": 6, "width": 4, }, { @@ -479,7 +479,7 @@ exports[`Slider renders centered variant 1`] = ` "left": 0, "right": undefined, "top": 0, - "width": 0, + "width": 8, }, { "backgroundColor": "rgba(232, 222, 248, 1)", @@ -503,7 +503,7 @@ exports[`Slider renders centered variant 1`] = ` { "bottom": 0, "height": undefined, - "left": 8, + "left": 16, "right": undefined, "top": 0, "width": 0, @@ -530,7 +530,7 @@ exports[`Slider renders centered variant 1`] = ` { "bottom": 0, "height": undefined, - "left": 0, + "left": 8, "right": undefined, "top": 0, "width": 0, @@ -556,7 +556,7 @@ exports[`Slider renders centered variant 1`] = ` "top": 0, }, { - "left": -2, + "left": 6, "width": 4, }, { @@ -658,7 +658,7 @@ exports[`Slider renders disabled standard 1`] = ` { "bottom": 0, "height": undefined, - "left": 8, + "left": 16, "right": undefined, "top": 0, "width": 0, @@ -711,7 +711,7 @@ exports[`Slider renders disabled standard 1`] = ` "top": 0, }, { - "left": -2, + "left": 6, "width": 4, }, { @@ -813,7 +813,7 @@ exports[`Slider renders each size 1`] = ` { "bottom": 0, "height": undefined, - "left": 8, + "left": 16, "right": undefined, "top": 0, "width": 0, @@ -866,7 +866,7 @@ exports[`Slider renders each size 1`] = ` "top": 0, }, { - "left": -2, + "left": 6, "width": 4, }, { @@ -968,7 +968,7 @@ exports[`Slider renders each size 2`] = ` { "bottom": 0, "height": undefined, - "left": 8, + "left": 16, "right": undefined, "top": 0, "width": 0, @@ -1021,7 +1021,7 @@ exports[`Slider renders each size 2`] = ` "top": 0, }, { - "left": -2, + "left": 6, "width": 4, }, { @@ -1123,7 +1123,7 @@ exports[`Slider renders each size 3`] = ` { "bottom": 0, "height": undefined, - "left": 8, + "left": 20, "right": undefined, "top": 0, "width": 0, @@ -1153,7 +1153,7 @@ exports[`Slider renders each size 3`] = ` "left": 0, "right": undefined, "top": 0, - "width": 0, + "width": 4, }, { "backgroundColor": "rgba(103, 80, 164, 1)", @@ -1176,7 +1176,7 @@ exports[`Slider renders each size 3`] = ` "top": 0, }, { - "left": -2, + "left": 10, "width": 4, }, { @@ -1278,7 +1278,7 @@ exports[`Slider renders each size 4`] = ` { "bottom": 0, "height": undefined, - "left": 8, + "left": 24, "right": undefined, "top": 0, "width": 0, @@ -1308,7 +1308,7 @@ exports[`Slider renders each size 4`] = ` "left": 0, "right": undefined, "top": 0, - "width": 0, + "width": 8, }, { "backgroundColor": "rgba(103, 80, 164, 1)", @@ -1331,7 +1331,7 @@ exports[`Slider renders each size 4`] = ` "top": 0, }, { - "left": -2, + "left": 14, "width": 4, }, { @@ -1433,7 +1433,7 @@ exports[`Slider renders each size 5`] = ` { "bottom": 0, "height": undefined, - "left": 8, + "left": 36, "right": undefined, "top": 0, "width": 0, @@ -1463,7 +1463,7 @@ exports[`Slider renders each size 5`] = ` "left": 0, "right": undefined, "top": 0, - "width": 0, + "width": 20, }, { "backgroundColor": "rgba(103, 80, 164, 1)", @@ -1486,7 +1486,7 @@ exports[`Slider renders each size 5`] = ` "top": 0, }, { - "left": -2, + "left": 26, "width": 4, }, { @@ -1615,7 +1615,7 @@ exports[`Slider renders range variant 1`] = ` { "bottom": 0, "height": undefined, - "left": 8, + "left": 16, "right": undefined, "top": 0, "width": 0, @@ -1642,7 +1642,7 @@ exports[`Slider renders range variant 1`] = ` { "bottom": 0, "height": undefined, - "left": 8, + "left": 16, "right": undefined, "top": 0, "width": 0, @@ -1668,7 +1668,7 @@ exports[`Slider renders range variant 1`] = ` "top": 0, }, { - "left": -2, + "left": 6, "width": 4, }, { @@ -1691,7 +1691,7 @@ exports[`Slider renders range variant 1`] = ` "top": 0, }, { - "left": -2, + "left": 6, "width": 4, }, { @@ -1793,7 +1793,7 @@ exports[`Slider renders standard slider 1`] = ` { "bottom": 0, "height": undefined, - "left": 8, + "left": 16, "right": undefined, "top": 0, "width": 0, @@ -1846,7 +1846,7 @@ exports[`Slider renders standard slider 1`] = ` "top": 0, }, { - "left": -2, + "left": 6, "width": 4, }, { @@ -2002,7 +2002,7 @@ exports[`Slider renders vertical orientation 1`] = ` }, { "height": 4, - "top": -2, + "top": -10, }, { "backgroundColor": "rgba(103, 80, 164, 1)", @@ -2103,7 +2103,7 @@ exports[`Slider renders with stop indicators 1`] = ` { "bottom": 0, "height": undefined, - "left": 8, + "left": 16, "right": undefined, "top": 0, "width": 0, @@ -2156,7 +2156,7 @@ exports[`Slider renders with stop indicators 1`] = ` "top": 0, }, { - "left": -2, + "left": 6, "width": 4, }, { @@ -2258,7 +2258,7 @@ exports[`Slider renders with value indicator 1`] = ` { "bottom": 0, "height": undefined, - "left": 8, + "left": 16, "right": undefined, "top": 0, "width": 0, @@ -2311,7 +2311,7 @@ exports[`Slider renders with value indicator 1`] = ` "top": 56, }, { - "left": -2, + "left": 6, "width": 4, }, { @@ -2338,7 +2338,7 @@ exports[`Slider renders with value indicator 1`] = ` "opacity": 0, "transform": [ { - "translateX": -24, + "translateX": -16, }, ], }, From afcd6ba98071c3a00cd8d58430b89ca60dfed837 Mon Sep 17 00:00:00 2001 From: Doberjohn Date: Fri, 31 Jul 2026 20:21:43 +0300 Subject: [PATCH 06/10] fixed padding in centered slider --- src/components/Slider/Slider.tsx | 40 ++++++++++--------- .../__snapshots__/Slider.test.tsx.snap | 4 +- 2 files changed, 23 insertions(+), 21 deletions(-) diff --git a/src/components/Slider/Slider.tsx b/src/components/Slider/Slider.tsx index 7a2619a2ce..744e116b64 100644 --- a/src/components/Slider/Slider.tsx +++ b/src/components/Slider/Slider.tsx @@ -322,10 +322,12 @@ const Slider = (props: Props) => { const hi = Math.max(sf, vf); height = Math.max(0, len - (origin + hi * usable) - gap); } else if (variant === 'centered') { - height = - vf >= 0.5 - ? Math.max(0, len - (origin + vf * usable) - gap) - : Math.max(0, len - (origin + 0.5 * usable)); + // Mirror of the left segment: the midpoint normally bounds this edge, + // but the handle has to bound it too once the active track vanishes. + height = Math.max( + 0, + len - Math.max(origin + 0.5 * usable, origin + vf * usable + gap) + ); } else { height = Math.max(0, len - (origin + vf * usable) - gap); } @@ -346,13 +348,8 @@ const Slider = (props: Props) => { left = origin + hi * usable + gap; width = Math.max(0, len - left); } else if (variant === 'centered') { - if (vf >= 0.5) { - left = origin + vf * usable + gap; - width = Math.max(0, len - left); - } else { - left = origin + 0.5 * usable; - width = Math.max(0, len - left); - } + left = Math.max(origin + 0.5 * usable, origin + vf * usable + gap); + width = Math.max(0, len - left); } else { left = origin + vf * usable + gap; width = Math.max(0, len - left); @@ -383,6 +380,11 @@ const Slider = (props: Props) => { // This segment always starts at the track's own leading edge, so its extent // is just the distance out to the handle it stops behind. + // + // Centered: normally it stops at the midpoint, where the active track takes + // over and holds it clear of the handle. But the active track shrinks to + // nothing once the handle is closer to the midpoint than `gap`, so the + // handle has to bound this edge too or the segment runs under it. if (isVertical) { let height: number; if (variant === 'range') { @@ -390,10 +392,10 @@ const Slider = (props: Props) => { height = Math.max(0, origin + lo * usable - gap); } else { // centered - height = - vf >= 0.5 - ? Math.max(0, origin + 0.5 * usable) - : Math.max(0, origin + vf * usable - gap); + height = Math.max( + 0, + Math.min(origin + 0.5 * usable, origin + vf * usable - gap) + ); } return { bottom: 0, @@ -411,10 +413,10 @@ const Slider = (props: Props) => { width = Math.max(0, origin + lo * usable - gap); } else { // centered - width = - vf >= 0.5 - ? Math.max(0, origin + 0.5 * usable) - : Math.max(0, origin + vf * usable - gap); + width = Math.max( + 0, + Math.min(origin + 0.5 * usable, origin + vf * usable - gap) + ); } return { left: 0, diff --git a/src/components/__tests__/__snapshots__/Slider.test.tsx.snap b/src/components/__tests__/__snapshots__/Slider.test.tsx.snap index 35ced38afa..4d6ad9d229 100644 --- a/src/components/__tests__/__snapshots__/Slider.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/Slider.test.tsx.snap @@ -116,7 +116,7 @@ exports[`Slider renders Slider.Centered shorthand 1`] = ` { "bottom": 0, "height": undefined, - "left": 8, + "left": 16, "right": undefined, "top": 0, "width": 0, @@ -479,7 +479,7 @@ exports[`Slider renders centered variant 1`] = ` "left": 0, "right": undefined, "top": 0, - "width": 8, + "width": 0, }, { "backgroundColor": "rgba(232, 222, 248, 1)", From 4186ab37f028b45a42bc7fe30acefeb0ea67befd Mon Sep 17 00:00:00 2001 From: Doberjohn Date: Fri, 31 Jul 2026 20:31:22 +0300 Subject: [PATCH 07/10] stop inverting slider drags for RTL until rendering mirrors --- src/components/Slider/utils.ts | 17 +++++++++++++++-- src/components/__tests__/Slider.test.tsx | 9 ++++++--- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/src/components/Slider/utils.ts b/src/components/Slider/utils.ts index c4d4ff86c3..b8145b27e4 100644 --- a/src/components/Slider/utils.ts +++ b/src/components/Slider/utils.ts @@ -32,6 +32,16 @@ export function fractionToValue( return snapToStep(raw, min, max, step); } +/** + * The slider renders left-to-right in every direction: nothing in its handle, + * track, corner radii or icon placement is mirrored for RTL. Honouring `isRTL` + * here while that is true inverts the drag against the handle, so that dragging + * right moves the handle right and lowers the value. + * + * Flip this to `true` in the same change that mirrors the rendering. + */ +const RTL_MIRRORING_IMPLEMENTED = false; + /** * Maps a touch position along the track axis onto a value fraction. * @@ -50,8 +60,11 @@ export function positionToFraction( ): number { const usable = trackLengthPx - insetStart - insetEnd; if (usable <= 0) return 0; - // Vertical: top of track = max, bottom = min. RTL horizontal: right = min. - const along = isVertical || isRTL ? trackLengthPx - touchPx : touchPx; + // Vertical: top of track = max, bottom = min. + const along = + isVertical || (isRTL && RTL_MIRRORING_IMPLEMENTED) + ? trackLengthPx - touchPx + : touchPx; return clamp((along - insetStart) / usable, 0, 1); } diff --git a/src/components/__tests__/Slider.test.tsx b/src/components/__tests__/Slider.test.tsx index a90e0070b2..f69b6de414 100644 --- a/src/components/__tests__/Slider.test.tsx +++ b/src/components/__tests__/Slider.test.tsx @@ -69,9 +69,12 @@ describe('positionToFraction', () => { 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); + // Deliberately not inverted: the rendering is not mirrored for RTL, so + // inverting here made dragging right move the handle right and lower the + // value. Flip this back alongside mirrored rendering. + it('does not invert for RTL while the rendering is unmirrored', () => { + expect(positionToFraction(0, 100, true, false)).toBe(0); + expect(positionToFraction(100, 100, true, false)).toBe(1); }); it('inverts for vertical (top=high, bottom=low)', () => { From ace283b0359ab446c551baec5f7ec27dd9b4a3ab Mon Sep 17 00:00:00 2001 From: Doberjohn Date: Fri, 31 Jul 2026 21:29:26 +0300 Subject: [PATCH 08/10] Fixed vertical sliders toggles --- src/components/Slider/Slider.tsx | 252 +++++++----------- .../__snapshots__/Slider.test.tsx.snap | 235 +++++----------- 2 files changed, 162 insertions(+), 325 deletions(-) diff --git a/src/components/Slider/Slider.tsx b/src/components/Slider/Slider.tsx index 744e116b64..ea09b5b8ab 100644 --- a/src/components/Slider/Slider.tsx +++ b/src/components/Slider/Slider.tsx @@ -215,6 +215,10 @@ const Slider = (props: Props) => { // gap = half handle width + between-handle space const trackHandleOffset = spec.handleWidth / 2 + BETWEEN_HANDLE_SPACE; + // Sizes across the track, perpendicular to the direction values grow. + const trackThickness = spec.trackThickness; + const handleCrossSize = spec.handleHeight; + // Where the handles travel: fraction 0 sits `handleInsetStart` px in from the // leading edge of the track, fraction 1 sits `handleInsetEnd` px back from the // trailing edge. Every handle, track segment, indicator, stop and touch @@ -240,69 +244,42 @@ const Slider = (props: Props) => { const usable = Math.max(0, len - handleInsetStart - handleInsetEnd); 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 = origin + lo * usable + gap; - height = Math.max(0, (hi - lo) * usable - 2 * gap); - } else if (variant === 'centered') { - if (vf >= 0.5) { - bottom = origin + 0.5 * usable; - height = Math.max(0, (vf - 0.5) * usable - gap); - } else { - bottom = origin + vf * usable + gap; - height = Math.max(0, (0.5 - vf) * usable - gap); - } - } else { - // Leading edge is the track's own end, not a handle position. - bottom = 0; - height = Math.max(0, origin + vf * usable - gap); - } - - return { - bottom, - height, - top: undefined, - left: 0, - right: 0, - width: undefined, - }; - } - - let left: number; - let width: number; + // `start` is the distance from the low-value end of the track and `length` + // the extent from there. Both are the same whichever way the track runs. + let start: number; + let length: number; if (variant === 'range') { const lo = Math.min(sf, vf); const hi = Math.max(sf, vf); - left = origin + lo * usable + gap; - width = Math.max(0, (hi - lo) * usable - 2 * gap); + start = origin + lo * usable + gap; + length = Math.max(0, (hi - lo) * usable - 2 * gap); } else if (variant === 'centered') { if (vf >= 0.5) { - left = origin + 0.5 * usable; - width = Math.max(0, (vf - 0.5) * usable - gap); + start = origin + 0.5 * usable; + length = Math.max(0, (vf - 0.5) * usable - gap); } else { - left = origin + vf * usable + gap; - width = Math.max(0, (0.5 - vf) * usable - gap); + start = origin + vf * usable + gap; + length = Math.max(0, (0.5 - vf) * usable - gap); } } else { // Leading edge is the track's own end, not a handle position. - left = 0; - width = Math.max(0, origin + vf * usable - gap); + start = 0; + length = Math.max(0, origin + vf * usable - gap); } - return { - left, - width, - top: 0, - bottom: 0, - right: undefined, - height: undefined, - }; + // Both branches must name the same keys with concrete numbers. An animated + // style cannot clear a property by returning `undefined`, so anything only + // one orientation sets would stay applied after switching to the other. + if (isVertical) { + return { + left: 0, + top: len - start - length, + width: trackThickness, + height: length, + }; + } + return { left: start, top: 0, width: length, height: trackThickness }; }); // Right inactive track: from (handle + gap) to track end, 2dp inner left corners @@ -314,63 +291,33 @@ const Slider = (props: Props) => { const usable = Math.max(0, len - handleInsetStart - handleInsetEnd); const gap = trackHandleOffset; - // This segment always ends at the track's own trailing edge, so its extent - // is measured back from `len` rather than from fraction 1. + // Runs from whichever edge bounds it out to the track's own far end. + let edge: number; + if (variant === 'range') { + edge = origin + Math.max(sf, vf) * usable + gap; + } else if (variant === 'centered') { + // The midpoint normally bounds this edge, but the handle has to bound it + // too once the active track shrinks to nothing near the centre. + edge = Math.max(origin + 0.5 * usable, origin + vf * usable + gap); + } else { + edge = origin + vf * usable + gap; + } + const length = Math.max(0, len - edge); + if (isVertical) { - let height: number; - if (variant === 'range') { - const hi = Math.max(sf, vf); - height = Math.max(0, len - (origin + hi * usable) - gap); - } else if (variant === 'centered') { - // Mirror of the left segment: the midpoint normally bounds this edge, - // but the handle has to bound it too once the active track vanishes. - height = Math.max( - 0, - len - Math.max(origin + 0.5 * usable, origin + vf * usable + gap) - ); - } else { - height = Math.max(0, len - (origin + vf * usable) - gap); - } return { - top: 0, - height, - bottom: undefined, left: 0, - right: 0, - width: undefined, + top: len - edge - length, + width: trackThickness, + height: length, }; } - - let left: number; - let width: number; - if (variant === 'range') { - const hi = Math.max(sf, vf); - left = origin + hi * usable + gap; - width = Math.max(0, len - left); - } else if (variant === 'centered') { - left = Math.max(origin + 0.5 * usable, origin + vf * usable + gap); - width = Math.max(0, len - left); - } else { - left = origin + vf * usable + gap; - width = Math.max(0, len - left); - } - return { - left, - width, - top: 0, - bottom: 0, - right: undefined, - height: undefined, - }; + return { left: edge, top: 0, width: length, height: trackThickness }; }); // 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; @@ -378,54 +325,34 @@ const Slider = (props: Props) => { const usable = Math.max(0, len - handleInsetStart - handleInsetEnd); const gap = trackHandleOffset; - // This segment always starts at the track's own leading edge, so its extent - // is just the distance out to the handle it stops behind. + // Always starts at the track's own near end, so only its extent varies. // // Centered: normally it stops at the midpoint, where the active track takes // over and holds it clear of the handle. But the active track shrinks to // nothing once the handle is closer to the midpoint than `gap`, so the // handle has to bound this edge too or the segment runs under it. - if (isVertical) { - let height: number; - if (variant === 'range') { - const lo = Math.min(sf, vf); - height = Math.max(0, origin + lo * usable - gap); - } else { - // centered - height = Math.max( - 0, - Math.min(origin + 0.5 * usable, origin + vf * usable - 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, origin + lo * usable - gap); + let length: number; + if (variant === 'standard') { + // Not rendered for this variant, but the keys still have to be concrete. + length = 0; + } else if (variant === 'range') { + length = Math.max(0, origin + Math.min(sf, vf) * usable - gap); } else { - // centered - width = Math.max( + length = Math.max( 0, Math.min(origin + 0.5 * usable, origin + vf * usable - gap) ); } - return { - left: 0, - width, - top: 0, - bottom: 0, - right: undefined, - height: undefined, - }; + + if (isVertical) { + return { + left: 0, + top: len - length, + width: trackThickness, + height: length, + }; + } + return { left: 0, top: 0, width: length, height: trackThickness }; }); // End handle animated position along the track axis @@ -434,12 +361,22 @@ const Slider = (props: Props) => { const origin = handleInsetStart; const usable = Math.max(0, len - handleInsetStart - handleInsetEnd); const w = endHandleWidthSV.value; - const along = origin + endFractionSV.value * usable; + const start = origin + endFractionSV.value * usable - w / 2; if (isVertical) { // Vertical runs bottom-to-top, so measure back from the far edge. - return { top: len - along - w / 2, height: w }; + return { + left: 0, + top: len - start - w, + width: handleCrossSize, + height: w, + }; } - return { left: along - w / 2, width: w }; + return { + left: start, + top: verticalOffset, + width: w, + height: handleCrossSize, + }; }); // Start handle animated position along the track axis @@ -448,11 +385,21 @@ const Slider = (props: Props) => { const origin = handleInsetStart; const usable = Math.max(0, len - handleInsetStart - handleInsetEnd); const w = startHandleWidthSV.value; - const along = origin + startFractionSV.value * usable; + const start = origin + startFractionSV.value * usable - w / 2; if (isVertical) { - return { top: len - along - w / 2, height: w }; + return { + left: 0, + top: len - start - w, + width: handleCrossSize, + height: w, + }; } - return { left: along - w / 2, width: w }; + return { + left: start, + top: verticalOffset, + width: w, + height: handleCrossSize, + }; }); // Value indicator animated style @@ -465,16 +412,15 @@ const Slider = (props: Props) => { if (isVertical) { return { opacity: valueIndicatorAlphaSV.value, - top: len - along - VALUE_INDICATOR_HEIGHT / 2, left: -(VALUE_INDICATOR_WIDTH + VALUE_INDICATOR_BOTTOM_SPACE), - right: undefined, - bottom: undefined, - transform: [], + top: len - along - VALUE_INDICATOR_HEIGHT / 2, }; } + // `left` rather than a transform, so both branches name the same keys. return { opacity: valueIndicatorAlphaSV.value, - transform: [{ translateX: along - VALUE_INDICATOR_WIDTH / 2 }], + left: along - VALUE_INDICATOR_WIDTH / 2, + top: 0, }; }); @@ -847,15 +793,6 @@ const Slider = (props: Props) => { ? { 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, @@ -1001,7 +938,6 @@ const Slider = (props: Props) => { { Date: Tue, 4 Aug 2026 19:34:10 +0300 Subject: [PATCH 09/10] fix(slider): expose the gesture wrapper to screen readers The wrapper is a bare View, so accessibilityRole, accessibilityValue and the increment/decrement actions never reached a screen reader without an explicit accessible prop. Also documents the props in JSDoc and migrates the tests to RNTL 14 and @jest/globals. --- src/components/Slider/Slider.tsx | 153 ++++++++++--- src/components/Slider/tokens.ts | 2 +- src/components/Slider/utils.ts | 2 +- src/components/__tests__/Slider.test.tsx | 212 +++++++++++------- .../__snapshots__/Slider.test.tsx.snap | 14 ++ 5 files changed, 271 insertions(+), 112 deletions(-) diff --git a/src/components/Slider/Slider.tsx b/src/components/Slider/Slider.tsx index ea09b5b8ab..a83ef89de6 100644 --- a/src/components/Slider/Slider.tsx +++ b/src/components/Slider/Slider.tsx @@ -46,24 +46,89 @@ import Icon, { type IconSource } from '../Icon'; import Text from '../Typography/Text'; type BaseProps = { + /** + * Lowest selectable value. Defaults to `0`. + */ min?: number; + /** + * Highest selectable value. Defaults to `100`. + */ max?: number; + /** + * Interval the value snaps to while dragging. `0`, the default, slides + * continuously. Also sets the spacing of the intermediate stop indicators + * drawn when `showStops` is enabled. + */ step?: number; + /** + * Track and handle dimensions, from `xs` to `xl`. Defaults to `s`. Only `m`, + * `l` and `xl` are tall enough to show an `icon`. + */ size?: SliderSize; + /** + * Axis the slider runs along. Defaults to `horizontal`. A `vertical` slider + * grows upwards from `min` and needs a height from its container or `style`, + * since it has no intrinsic length. + */ orientation?: 'horizontal' | 'vertical'; + /** + * Whether the slider ignores touches and dims its colors. Defaults to `false`. + */ disabled?: boolean; + /** + * Icon inset into the leading end of the active track. Ignored at sizes `xs` + * and `s`, which are too short to fit one, and while `disabled`. + */ icon?: IconSource; + /** + * Whether to mark each `step` with a stop indicator. Requires `step` to be + * greater than `0`. The indicators at `min` and `max` are always drawn. + */ showStops?: boolean; + /** + * Whether to show a bubble above the handle with the current value while + * dragging. Defaults to `false`. + */ showValueIndicator?: boolean; + /** + * Formats the text inside the value indicator. Receives the value being + * dragged and defaults to rounding it to a whole number. + */ valueIndicatorLabel?: (v: number) => string; + /** + * Overrides the active track and handle color for this instance, in place of + * the theme's `primary` role. + */ color?: ColorValue; + /** + * Style for sizing and positioning the slider. Thickness comes from `size`, + * so only the length along the track is yours to set. A `height` when + * `orientation` is `vertical`, a `width` or `flex` when it is `horizontal`. + */ style?: StyleProp; + /** + * testID to be used on tests. + */ testID?: string; + /** + * @optional + */ theme?: ThemeProp; + /** + * Label read out by the screen reader in place of the slider's value. + */ accessibilityLabel?: string; }; -type StandardProps = BaseProps & { +/** + * `value` and the change callbacks depend on `variant`, so they live in a + * discriminated union intersected onto the shared props. The same shape + * `SegmentedButtons` uses for its conditional `value`. Keeping the union out of + * the top level lets the docs generator resolve the shared half; the props in + * here are documented in the component's JSDoc instead, since the generated + * table cannot express them. + */ +type StandardValue = { variant?: 'standard'; value: number; onValueChange?: (v: number) => void; @@ -71,7 +136,7 @@ type StandardProps = BaseProps & { onSlidingComplete?: (v: number) => void; }; -type CenteredProps = BaseProps & { +type CenteredValue = { variant: 'centered'; value: number; onValueChange?: (v: number) => void; @@ -79,7 +144,7 @@ type CenteredProps = BaseProps & { onSlidingComplete?: (v: number) => void; }; -type RangeProps = BaseProps & { +type RangeValue = { variant: 'range'; value: [number, number]; onValueChange?: (v: [number, number]) => void; @@ -87,7 +152,9 @@ type RangeProps = BaseProps & { onSlidingComplete?: (v: [number, number]) => void; }; -export type Props = StandardProps | CenteredProps | RangeProps; +type ConditionalValue = StandardValue | CenteredValue | RangeValue; + +export type Props = BaseProps & ConditionalValue; /** * Material 3 slider for selecting a value from a range. @@ -103,6 +170,38 @@ export type Props = StandardProps | CenteredProps | RangeProps; * return ; * }; * ``` + * + * `Slider.Centered` fills outwards from the midpoint, and `Slider.Range` takes a + * `[start, end]` tuple and shows two handles: + * + * ```js + * const [range, setRange] = React.useState([20, 75]); + * return ; + * ``` + * + * ## Variants and `value` + * `variant` decides the type of `value` and of the three change callbacks, so + * none of them can appear in the props table below: + * + * | `variant` | `value` | callbacks receive | + * | --- | --- | --- | + * | `'standard'` (default) | `number` | `number` | + * | `'centered'` | `number` | `number` | + * | `'range'` | `[number, number]` | `[number, number]` | + * + * `value` is required; `onValueChange`, `onSlidingStart` and `onSlidingComplete` + * are optional. `Slider.Centered` and `Slider.Range` set `variant` for you, so + * you never pass it directly with those. + * + * ## Theming + * Customize by overriding these `theme.colors` roles: + * - `primary`: active track and handles. Override per instance with `color` + * - `secondaryContainer`: inactive track + * - `onPrimary`: stop indicators sitting on the active track + * - `onSecondaryContainer`: stop indicators sitting on the inactive track + * - `inverseSurface` / `inverseOnSurface`: value indicator background / label + * - `onSurface`: every element while `disabled`, at 38% opacity, or 12% for the + * inactive track */ const Slider = (props: Props) => { const { @@ -159,9 +258,9 @@ const Slider = (props: Props) => { ); const initialEndValue = isRange - ? (props as RangeProps).value[1] - : (props as StandardProps | CenteredProps).value; - const initialStartValue = isRange ? (props as RangeProps).value[0] : min; + ? (props as RangeValue).value[1] + : (props as StandardValue | CenteredValue).value; + const initialStartValue = isRange ? (props as RangeValue).value[0] : min; const [endValue, setEndValue] = React.useState(initialEndValue); const [startValue, setStartValue] = React.useState(initialStartValue); @@ -170,11 +269,11 @@ const Slider = (props: Props) => { React.useEffect(() => { if (isRange) { - const [s, e] = (props as RangeProps).value; + const [s, e] = (props as RangeValue).value; setStartValue(s); setEndValue(e); } else { - const v = (props as StandardProps | CenteredProps).value; + const v = (props as StandardValue | CenteredValue).value; setEndValue(v); } }, [props, isRange]); @@ -226,7 +325,7 @@ const Slider = (props: Props) => { // change it. // // The corner radii put each end of the range at the centre of its rounded cap, - // which is where the end stops are drawn — so a handle at min or max lands on + // which is where the end stops are drawn so that a handle at min or max lands on // its own stop instead of overshooting it by the radius. const handleInsetStart = spec.activeLeadingRadius; const handleInsetEnd = spec.inactiveTrailingRadius; @@ -524,9 +623,9 @@ const Slider = (props: Props) => { const p = propsRef.current; if (vt === 'range') { - (p as RangeProps).onSlidingStart?.([sv, ev]); + (p as RangeValue).onSlidingStart?.([sv, ev]); } else { - (p as StandardProps | CenteredProps).onSlidingStart?.(ev); + (p as StandardValue | CenteredValue).onSlidingStart?.(ev); } }, @@ -598,19 +697,19 @@ const Slider = (props: Props) => { startFractionSV.value = valueToFraction(clamped, mn, mx); setStartValue(clamped); setIndicatorDisplayValue(clamped); - (p as RangeProps).onValueChange?.([clamped, ev]); + (p as RangeValue).onValueChange?.([clamped, ev]); } else { const clamped = Math.max(snapped, sv); endFractionSV.value = valueToFraction(clamped, mn, mx); setEndValue(clamped); setIndicatorDisplayValue(clamped); - (p as RangeProps).onValueChange?.([sv, clamped]); + (p as RangeValue).onValueChange?.([sv, clamped]); } } else { endFractionSV.value = displayFraction; setEndValue(snapped); setIndicatorDisplayValue(snapped); - (p as StandardProps | CenteredProps).onValueChange?.(snapped); + (p as StandardValue | CenteredValue).onValueChange?.(snapped); } }, @@ -635,9 +734,9 @@ const Slider = (props: Props) => { const p = propsRef.current; if (vt === 'range') { - (p as RangeProps).onSlidingComplete?.([sv, ev]); + (p as RangeValue).onSlidingComplete?.([sv, ev]); } else { - (p as StandardProps | CenteredProps).onSlidingComplete?.(ev); + (p as StandardValue | CenteredValue).onSlidingComplete?.(ev); } }, @@ -666,7 +765,7 @@ const Slider = (props: Props) => { setEndValue(next); endFractionSV.value = valueToFraction(next, min, max); if (!isRange) { - (props as StandardProps | CenteredProps).onValueChange?.(next); + (props as StandardValue | CenteredValue).onValueChange?.(next); } }; @@ -677,7 +776,7 @@ const Slider = (props: Props) => { setEndValue(next); endFractionSV.value = valueToFraction(next, min, max); if (!isRange) { - (props as StandardProps | CenteredProps).onValueChange?.(next); + (props as StandardValue | CenteredValue).onValueChange?.(next); } }; @@ -698,14 +797,14 @@ const Slider = (props: Props) => { variant === 'centered' ? Math.min(0.5, endFraction) : variant === 'range' - ? Math.min(startFraction, endFraction) - : 0; + ? Math.min(startFraction, endFraction) + : 0; const activeTrail = variant === 'centered' ? Math.max(0.5, endFraction) : variant === 'range' - ? Math.max(startFraction, endFraction) - : endFraction; + ? Math.max(startFraction, endFraction) + : endFraction; const indicatorText = valueIndicatorLabel ? valueIndicatorLabel(indicatorDisplayValue) @@ -810,6 +909,9 @@ const Slider = (props: Props) => { { style={trackContainerStyle} onLayout={onLayout} pointerEvents="none" + testID={testID ? `${testID}-track` : undefined} > {/* Left inactive track segment (centered/range only) */} {variant !== 'standard' && ( @@ -893,8 +996,8 @@ const Slider = (props: Props) => { backgroundColor: disabled ? colors.disabledContent : isActive - ? colors.stopOnActive - : colors.stopOnInactive, + ? colors.stopOnActive + : colors.stopOnInactive, opacity: contentOpacity, }, ]} diff --git a/src/components/Slider/tokens.ts b/src/components/Slider/tokens.ts index 146b0c842f..40ac4ed6fc 100644 --- a/src/components/Slider/tokens.ts +++ b/src/components/Slider/tokens.ts @@ -67,7 +67,7 @@ export const SIZE_SPECS: Record = { } as const; export const STOP_SIZE = 4; -/** md.comp.slider active handle leading/trailing space — the gap either side of the handle. */ +/** md.comp.slider active handle leading/trailing space: the gap either side of the handle. */ export const BETWEEN_HANDLE_SPACE = 6; export const INNER_CORNER_RADIUS = 2; /** Label container: 48dp wide by 44dp tall, so not a square. */ diff --git a/src/components/Slider/utils.ts b/src/components/Slider/utils.ts index b8145b27e4..32b593b18a 100644 --- a/src/components/Slider/utils.ts +++ b/src/components/Slider/utils.ts @@ -122,7 +122,7 @@ export const HANDLE_DIRECTION_THRESHOLD = 1; * Picks the handle a range gesture should drag. * * While the handles are apart it is simply the nearest one. Once they sit on the - * same value there is no nearest handle — every touch is equidistant — and + * same value there is no nearest handle (every touch is equidistant), and * guessing deadlocks the gesture: with both at `max`, the end handle is floored * by the start handle and cannot move, while the start handle can never be * picked. So overlap defers to 'pending', and the drag direction decides. diff --git a/src/components/__tests__/Slider.test.tsx b/src/components/__tests__/Slider.test.tsx index f69b6de414..21e0bc3591 100644 --- a/src/components/__tests__/Slider.test.tsx +++ b/src/components/__tests__/Slider.test.tsx @@ -1,6 +1,6 @@ -import * as React from 'react'; +import { describe, expect, it, jest } from '@jest/globals'; -import { act, fireEvent, render } from '../../test-utils'; +import { fireEvent, render, screen } from '../../test-utils'; import Slider from '../Slider'; import { activeSegment, @@ -169,7 +169,7 @@ describe('rangeHandleForTouch', () => { }); // Overlapping handles make every touch equidistant, so nearest-handle would - // always answer 'end' — and at max that handle is the one that cannot move. + // always answer 'end', and at max that handle is the one that cannot move. it('defers to the drag direction when the handles overlap', () => { expect(rangeHandleForTouch(0.5, 1, 1)).toBe('pending'); expect(rangeHandleForTouch(0, 0, 0)).toBe('pending'); @@ -180,64 +180,74 @@ describe('rangeHandleForTouch', () => { // ---- Component render tests ---- describe('Slider renders', () => { - it('standard slider', () => { - const tree = render().toJSON(); + it('standard slider', async () => { + const tree = (await render()).toJSON(); expect(tree).toMatchSnapshot(); }); - it('each size', () => { + it('each size', async () => { for (const size of ['xs', 's', 'm', 'l', 'xl'] as const) { - const tree = render().toJSON(); + const tree = (await render()).toJSON(); expect(tree).toMatchSnapshot(); } }); - it('centered variant', () => { - const tree = render().toJSON(); + it('centered variant', async () => { + const tree = ( + await render() + ).toJSON(); expect(tree).toMatchSnapshot(); }); - it('range variant', () => { - const tree = render().toJSON(); + it('range variant', async () => { + const tree = ( + await render() + ).toJSON(); expect(tree).toMatchSnapshot(); }); - it('disabled standard', () => { - const tree = render().toJSON(); + it('disabled standard', async () => { + const tree = (await render()).toJSON(); expect(tree).toMatchSnapshot(); }); - it('with stop indicators', () => { - const tree = render().toJSON(); + it('with stop indicators', async () => { + const tree = ( + await render() + ).toJSON(); expect(tree).toMatchSnapshot(); }); - it('with value indicator', () => { - const tree = render().toJSON(); + it('with value indicator', async () => { + const tree = ( + await render() + ).toJSON(); expect(tree).toMatchSnapshot(); }); - it('vertical orientation', () => { - const tree = render().toJSON(); + it('vertical orientation', async () => { + const tree = ( + await render() + ).toJSON(); expect(tree).toMatchSnapshot(); }); - it('Slider.Centered shorthand', () => { - const tree = render().toJSON(); + it('Slider.Centered shorthand', async () => { + const tree = (await render()).toJSON(); expect(tree).toMatchSnapshot(); }); - it('Slider.Range shorthand', () => { - const tree = render().toJSON(); + it('Slider.Range shorthand', async () => { + const tree = (await 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({ + it('has adjustable role with correct value', async () => { + await render(); + + expect(screen.getByRole('adjustable')).toHaveAccessibilityValue({ min: 0, max: 100, now: 42, @@ -245,34 +255,6 @@ describe('Slider accessibility', () => { }); }); -// The PanResponder is built once and never rebuilt, so `disabled` has to be -// read through a ref. These assert it is, in both directions. -describe('Slider disabled', () => { - it('stops accepting gestures when disabled is toggled on after mount', () => { - const { getByRole, update } = render(); - expect(getByRole('adjustable').props.onStartShouldSetResponder()).toBe( - true - ); - - update(); - expect(getByRole('adjustable').props.onStartShouldSetResponder()).toBe( - false - ); - }); - - it('accepts gestures again when disabled is toggled back off', () => { - const { getByRole, update } = render(); - expect(getByRole('adjustable').props.onStartShouldSetResponder()).toBe( - false - ); - - update(); - expect(getByRole('adjustable').props.onStartShouldSetResponder()).toBe( - true - ); - }); -}); - // Synthetic touches, shaped the way PanResponder's internals expect: it reads // the centroid out of `touchHistory.touchBank`, and derives dx from the gap // between a touch's previous and current position, for touches whose timestamp @@ -319,72 +301,132 @@ const TRACK_WIDTH = 300; // Nothing lays out in the test renderer, so the track measures 0 and every // touch maps to fraction 0. Fire the layout the component is waiting for. -const layoutTrack = (r: ReturnType) => { - const nodes = r.container.findAll( - (n) => typeof n.props.onLayout === 'function' - ); - fireEvent(nodes[nodes.length - 1], 'layout', { +// Needs the slider rendered with `testID="slider"`. +const layoutTrack = async () => { + await fireEvent(screen.getByTestId('slider-track'), 'layout', { nativeEvent: { layout: { width: TRACK_WIDTH, height: 44 } }, }); }; -const drag = (r: ReturnType, fromX: number, toX: number) => { - const view = r.getByRole('adjustable'); - act(() => { - view.props.onResponderGrant(grantEvent(fromX)); - view.props.onResponderMove(touchEvent(fromX, toX)); - }); +// Each `fireEvent` has to be awaited: it settles its own `act()`, and the grant +// has to finish before the move arrives or the drag direction is never seen. +// +// `fireEvent` also refuses to deliver to a view whose `onStartShouldSetResponder` +// returns false, so a disabled slider drops these on the floor by itself. +const drag = async (fromX: number, toX: number) => { + const slider = screen.getByRole('adjustable'); + await fireEvent(slider, 'responderGrant', grantEvent(fromX)); + await fireEvent(slider, 'responderMove', touchEvent(fromX, toX)); }; +// The PanResponder is built once and never rebuilt, so `disabled` has to be +// read through a ref. These assert it is, in both directions, by checking +// whether a drag still reaches the value. +describe('Slider disabled', () => { + it('stops accepting gestures when disabled is toggled on after mount', async () => { + const onValueChange = jest.fn(); + const view = await render( + + ); + await layoutTrack(); + + await drag(150, 79); + expect(onValueChange).toHaveBeenCalledWith(25); + + onValueChange.mockClear(); + await view.rerender( + + ); + + await drag(150, 79); + expect(onValueChange).not.toHaveBeenCalled(); + }); + + it('accepts gestures again when disabled is toggled back off', async () => { + const onValueChange = jest.fn(); + const view = await render( + + ); + await layoutTrack(); + + await drag(150, 79); + expect(onValueChange).not.toHaveBeenCalled(); + + await view.rerender( + + ); + + await drag(150, 79); + expect(onValueChange).toHaveBeenCalledWith(25); + }); +}); + describe('Slider range gestures', () => { - const setup = (value: [number, number]) => { + const setup = async (value: [number, number]) => { const onValueChange = jest.fn(); - const r = render( + await render( ); - layoutTrack(r); - return { r, onValueChange }; + await layoutTrack(); + return { onValueChange }; }; // Handles travel an inset range, so on a 300px track at size `s` (8dp insets) // the usable span is 284: fraction 0.25 is at pixel 79, 0.5 at 150, 0.75 at 221. - it('frees the start handle when both are pinned at max', () => { - const { r, onValueChange } = setup([100, 100]); - drag(r, 150, 79); + it('frees the start handle when both are pinned at max', async () => { + const { onValueChange } = await setup([100, 100]); + await drag(150, 79); expect(onValueChange).toHaveBeenCalledWith([25, 100]); }); - it('frees the end handle when both are pinned at min', () => { - const { r, onValueChange } = setup([0, 0]); - drag(r, 150, 221); + it('frees the end handle when both are pinned at min', async () => { + const { onValueChange } = await setup([0, 0]); + await drag(150, 221); expect(onValueChange).toHaveBeenCalledWith([0, 75]); }); - it('still drags the nearest handle when they are apart', () => { - const { r, onValueChange } = setup([20, 80]); - drag(r, 221, 150); + it('still drags the nearest handle when they are apart', async () => { + const { onValueChange } = await setup([20, 80]); + await drag(221, 150); expect(onValueChange).toHaveBeenCalledWith([20, 50]); }); }); describe('Slider gesture callbacks', () => { - it('calls the callback from the latest render, not the first', () => { + it('calls the callback from the latest render, not the first', async () => { const first = jest.fn(); const second = jest.fn(); - const { getByRole, update } = render( - + const view = await render(); + await fireEvent( + screen.getByRole('adjustable'), + 'responderGrant', + grantEvent(10) ); - getByRole('adjustable').props.onResponderGrant(grantEvent(10)); expect(first).toHaveBeenCalledTimes(1); - update(); - getByRole('adjustable').props.onResponderGrant(grantEvent(10)); + await view.rerender(); + await fireEvent( + screen.getByRole('adjustable'), + 'responderGrant', + grantEvent(10) + ); expect(second).toHaveBeenCalledTimes(1); expect(first).toHaveBeenCalledTimes(1); diff --git a/src/components/__tests__/__snapshots__/Slider.test.tsx.snap b/src/components/__tests__/__snapshots__/Slider.test.tsx.snap index 47c3ba7204..498e667a8a 100644 --- a/src/components/__tests__/__snapshots__/Slider.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/Slider.test.tsx.snap @@ -38,6 +38,7 @@ exports[`Slider renders Slider.Centered shorthand 1`] = ` "now": 30, } } + accessible={true} onAccessibilityAction={[Function]} onMoveShouldSetResponder={[Function]} onMoveShouldSetResponderCapture={[Function]} @@ -212,6 +213,7 @@ exports[`Slider renders Slider.Range shorthand 1`] = ` "now": 90, } } + accessible={true} onAccessibilityAction={[Function]} onMoveShouldSetResponder={[Function]} onMoveShouldSetResponderCapture={[Function]} @@ -407,6 +409,7 @@ exports[`Slider renders centered variant 1`] = ` "now": 75, } } + accessible={true} onAccessibilityAction={[Function]} onMoveShouldSetResponder={[Function]} onMoveShouldSetResponderCapture={[Function]} @@ -581,6 +584,7 @@ exports[`Slider renders disabled standard 1`] = ` "now": 50, } } + accessible={true} onAccessibilityAction={[Function]} onMoveShouldSetResponder={[Function]} onMoveShouldSetResponderCapture={[Function]} @@ -730,6 +734,7 @@ exports[`Slider renders each size 1`] = ` "now": 50, } } + accessible={true} onAccessibilityAction={[Function]} onMoveShouldSetResponder={[Function]} onMoveShouldSetResponderCapture={[Function]} @@ -879,6 +884,7 @@ exports[`Slider renders each size 2`] = ` "now": 50, } } + accessible={true} onAccessibilityAction={[Function]} onMoveShouldSetResponder={[Function]} onMoveShouldSetResponderCapture={[Function]} @@ -1028,6 +1034,7 @@ exports[`Slider renders each size 3`] = ` "now": 50, } } + accessible={true} onAccessibilityAction={[Function]} onMoveShouldSetResponder={[Function]} onMoveShouldSetResponderCapture={[Function]} @@ -1177,6 +1184,7 @@ exports[`Slider renders each size 4`] = ` "now": 50, } } + accessible={true} onAccessibilityAction={[Function]} onMoveShouldSetResponder={[Function]} onMoveShouldSetResponderCapture={[Function]} @@ -1326,6 +1334,7 @@ exports[`Slider renders each size 5`] = ` "now": 50, } } + accessible={true} onAccessibilityAction={[Function]} onMoveShouldSetResponder={[Function]} onMoveShouldSetResponderCapture={[Function]} @@ -1475,6 +1484,7 @@ exports[`Slider renders range variant 1`] = ` "now": 80, } } + accessible={true} onAccessibilityAction={[Function]} onMoveShouldSetResponder={[Function]} onMoveShouldSetResponderCapture={[Function]} @@ -1670,6 +1680,7 @@ exports[`Slider renders standard slider 1`] = ` "now": 50, } } + accessible={true} onAccessibilityAction={[Function]} onMoveShouldSetResponder={[Function]} onMoveShouldSetResponderCapture={[Function]} @@ -1819,6 +1830,7 @@ exports[`Slider renders vertical orientation 1`] = ` "now": 50, } } + accessible={true} onAccessibilityAction={[Function]} onMoveShouldSetResponder={[Function]} onMoveShouldSetResponderCapture={[Function]} @@ -1968,6 +1980,7 @@ exports[`Slider renders with stop indicators 1`] = ` "now": 50, } } + accessible={true} onAccessibilityAction={[Function]} onMoveShouldSetResponder={[Function]} onMoveShouldSetResponderCapture={[Function]} @@ -2117,6 +2130,7 @@ exports[`Slider renders with value indicator 1`] = ` "now": 50, } } + accessible={true} onAccessibilityAction={[Function]} onMoveShouldSetResponder={[Function]} onMoveShouldSetResponderCapture={[Function]} From d08c69442383fd6897132afd37caead5102f4df8 Mon Sep 17 00:00:00 2001 From: Doberjohn Date: Tue, 4 Aug 2026 19:43:16 +0300 Subject: [PATCH 10/10] docs: add Slider to the docs site Registers Slider in the docs config and screenshot data, and adds the generated 6.x page. Also keeps components added after 5.x out of the 5.x sidebar, which otherwise fails the docs build since Slider has no pages under docs/5.x. --- docs/6.x/docs/components/Slider/Slider.mdx | 195 ++++++++++++++++++++ docs/6.x/docs/components/Slider/_meta.json | 3 + docs/6.x/docs/components/_meta.json | 7 + docs/component-docs.config.ts | 3 + docs/public/screenshots/slider-centered.png | Bin 0 -> 15994 bytes docs/public/screenshots/slider-range.png | Bin 0 -> 16008 bytes docs/public/screenshots/slider-standard.png | Bin 0 -> 21269 bytes docs/public/screenshots/slider-vertical.png | Bin 0 -> 20732 bytes docs/scripts/generate-rspress-content.ts | 16 +- docs/src/data/componentDocs6x.json | 155 ++++++++++++++++ docs/src/data/screenshots.ts | 6 + 11 files changed, 381 insertions(+), 4 deletions(-) create mode 100644 docs/6.x/docs/components/Slider/Slider.mdx create mode 100644 docs/6.x/docs/components/Slider/_meta.json create mode 100644 docs/public/screenshots/slider-centered.png create mode 100644 docs/public/screenshots/slider-range.png create mode 100644 docs/public/screenshots/slider-standard.png create mode 100644 docs/public/screenshots/slider-vertical.png diff --git a/docs/6.x/docs/components/Slider/Slider.mdx b/docs/6.x/docs/components/Slider/Slider.mdx new file mode 100644 index 0000000000..b5eecc38ab --- /dev/null +++ b/docs/6.x/docs/components/Slider/Slider.mdx @@ -0,0 +1,195 @@ +--- +title: Slider +--- + +import PropTable from '@docs/components/PropTable.tsx'; +import ExtendsLink from '@docs/components/ExtendsLink.tsx'; +import ThemeColorsTable from '@docs/components/ThemeColorsTable.tsx'; +import ScreenshotTabs from '@docs/components/ScreenshotTabs.tsx'; +import ExtendedExample from '@docs/components/ExtendedExample.tsx'; + +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 ; +}; +``` + +`Slider.Centered` fills outwards from the midpoint, and `Slider.Range` takes a +`[start, end]` tuple and shows two handles: + +```js +const [range, setRange] = React.useState([20, 75]); +return ; +``` + +## Variants and `value` +`variant` decides the type of `value` and of the three change callbacks, so +none of them can appear in the props table below: + +| `variant` | `value` | callbacks receive | +| --- | --- | --- | +| `'standard'` (default) | `number` | `number` | +| `'centered'` | `number` | `number` | +| `'range'` | `[number, number]` | `[number, number]` | + +`value` is required; `onValueChange`, `onSlidingStart` and `onSlidingComplete` +are optional. `Slider.Centered` and `Slider.Range` set `variant` for you, so +you never pass it directly with those. + +## Theming +Customize by overriding these `theme.colors` roles: +- `primary`: active track and handles. Override per instance with `color` +- `secondaryContainer`: inactive track +- `onPrimary`: stop indicators sitting on the active track +- `onSecondaryContainer`: stop indicators sitting on the inactive track +- `inverseSurface` / `inverseOnSurface`: value indicator background / label +- `onSurface`: every element while `disabled`, at 38% opacity, or 12% for the + inactive track + + + ## Props + + + + +
+ +### min + +
+ + + +
+ +### max + +
+ + + +
+ +### step + +
+ + + +
+ +### size + +
+ + + +
+ +### orientation + +
+ + + +
+ +### disabled + +
+ + + +
+ +### icon + +
+ + + +
+ +### showStops + +
+ + + +
+ +### showValueIndicator + +
+ + + +
+ +### valueIndicatorLabel + +
+ + + +
+ +### color + +
+ + + +
+ +### style + +
+ + + +
+ +### testID + +
+ + + +
+ +### theme + +
+ + + +
+ +### accessibilityLabel + +
+ + + + + + + + + + diff --git a/docs/6.x/docs/components/Slider/_meta.json b/docs/6.x/docs/components/Slider/_meta.json new file mode 100644 index 0000000000..ad256957d0 --- /dev/null +++ b/docs/6.x/docs/components/Slider/_meta.json @@ -0,0 +1,3 @@ +[ + "Slider" +] diff --git a/docs/6.x/docs/components/_meta.json b/docs/6.x/docs/components/_meta.json index 6f793d41a3..59d0cb2435 100644 --- a/docs/6.x/docs/components/_meta.json +++ b/docs/6.x/docs/components/_meta.json @@ -126,6 +126,13 @@ "collapsible": true, "collapsed": false }, + { + "type": "dir", + "name": "Slider", + "label": "Slider", + "collapsible": true, + "collapsed": false + }, "Snackbar", "Surface", { diff --git a/docs/component-docs.config.ts b/docs/component-docs.config.ts index bad25d4eec..4de01be398 100644 --- a/docs/component-docs.config.ts +++ b/docs/component-docs.config.ts @@ -119,6 +119,9 @@ const pages = { SegmentedButtons: { SegmentedButtons: 'SegmentedButtons/SegmentedButtons', }, + Slider: { + Slider: 'Slider/Slider', + }, Snackbar: 'Snackbar', Surface: 'Surface', Switch: { diff --git a/docs/public/screenshots/slider-centered.png b/docs/public/screenshots/slider-centered.png new file mode 100644 index 0000000000000000000000000000000000000000..3d5f6afad2be260038d5af6fe6d5a57e7620fb1f GIT binary patch literal 15994 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 literal 0 HcmV?d00001 diff --git a/docs/public/screenshots/slider-range.png b/docs/public/screenshots/slider-range.png new file mode 100644 index 0000000000000000000000000000000000000000..b0d7445026a97133ebe62ef9c9831d1567e00f7d GIT binary patch literal 16008 zcmeHuWmr_-_wURwzz6~ZDhLV;D4~=hq2$mF(p}QsT|DIwj0bW5j_(%pz49nzBb zjKAOatIxgn#eH|*{CS?4vuB^P*Is+YXRW>V;mu1~F+3bH91sYECm}AZ00Lpafc7X1 z3;2Fc2%QIkZc3R63B8mM5<jTUb=dt1Lhdd~<-EwF1=U9Ls7sgfTL>z!h8{v9L3me|C=OjjynSY78<^35 zd!cvc9!S1gl{`k{dh5DGM2bEd7lh12$lmI~tc6p8KsLnUMs6T@P9$eqDxLC@GJLZu z-5;*XU>AhL$zYd=qr3CzP3Skl3n-|ZD~+WWgv`R;FZ2|^8qH`o^?fHyABWt+E|as~ zQKI^MKvnI_VJlsykd|_sBHOXRHWI}4>*6g{VMtDMB*tfZGkl*P*9qa*+V&2&{SDOG z^HU$;zLD>^d2syD!~8u_fzX9cG%?|gG7bPiRDv?W5x|3EnSFWpnOZto%-m9$jwCtw*)(?d@X%B=!N&S zKBHQMc-tGwQNnJz0D=zc+nCV&SDoF|5}t)2ok!VjS5Naj?6}QjM^U^xEIl_=_Ko44 z-3JEPyL|)x?-X+)NTd!jmz2Dh?x-9yveqr=20=xVI3~|ae#e75m zT==TrlSqtfhO^j*LKF{CbdiMD?GN}0A$NuF3wy471_;U*?&2G^Vc-3-7|eK$0u__wFprxZKl@VmVg_Yhg==Kj5VNV&%rSTe_B?B-GXacW z66bvUHFriGzg*hcr%j!jR@E;Bub z@HT%)-yS#n7Ms>xqxzR_0Qq z?eF?(V7C}T_3GzCNT)ob37rWiC}uNa z?i<~=LC*dlym(@EP(L{lz2UFN_^LXvyv zJ=R0MTm25C@9~ZOG1KqH_!;T3=Yj6E62HWA_Vr2!v-#gzyjcTd_TOA&PUcNnq*(*o zw9-KZ#9?^)h_m1uuii{P=!f0=DFBJGeTXF?s1S8i*pEJPs!K5Ywz{tpA}NZ@ki{PM z!Z%rLI%=j%C>!_JtzW^f^0@N-$ud;dOk=|A2cNLvrvwIGcIzCH3#eN%RS0>I#D%EK?sZ|JNXwNU@D!jqBTA+qK9_H2Z<~fGR zH+);H*NZ=EKxsh(Ur4^+ab?$uNNX!uBKS#h!Qdk!(sBvrVTm5_Cuhjk2%(TUHJG2yZ)wd{bc}P5r=Ic&fmI-bLtN4iI$ zM@9113Q^9x{Wtj{RUh0HzvrosVvgFIlOM4iQ5=OOCMG5%zSgKrRI_T*sGX*$axf0i|kl(N&OWmio|zX?G>hG?510_QP%Pb|aQhyS#rc)#&?N z`H%7?b|T;JqzHb>c36A2j(Ny>=6y!v{)tv(MDkrh_e64m`bRhMgAc|f#6lKtRkBrz z$6pnZYphnKTD+?)s>G-YoEDkZtduk7H`cr9fE7=r$7pD2lx*y{0e32Na^+Iz%HgVa zoOX0~ta8$Jyya-Jrm{gXw5|6_nt?VVp*mG0;=I$ntFP=Yu`bWCYJ5`!Dq9`>@vw67>clqQ9+Ew}tIle_(8V2; zmyx3X_8Vh6Q&3S*qiBa{tEfU&l_*_UU06bgcZYgM!P^KjmxL$Mp3!A4(3@ZgsyN9}pi<;NA-TNnF8X z&a|VZz&I6`5NE+SpwU!OUM*R9Hk9AGQCpSDK2XS*$E$_xW)CsR(j+;f68_;W`At|M8|LkR#*yWwcVBl zi3dqkFIK!}@y&^!#r$>EHQsg(F7Il`YMp8|YjW#K9Gbhw^~K6aDhAx(JQut_+}J!k z_Z$zt9?Y)(dL8etT0MbZviiNS^G$~p`N`zW*QDLXsrvTUEAKWcx^vqnM&oxiSI)Zz zX64pC_3e^&#=ZM8)$O*jyUcRHzjhtI*`3nPOM$|KV#h!$G0m|%uvMUZFjm|iSU%py z4QIU6JDxlNa3bPzo@LsBV7kSzJ$J!5q1Av?97TdqyaJfVZEF%{TpO~8a02pX`-?_c zFHAyM=wa}(Nb%bSN+WW1rVU%Rr(BQgSkKJ9zJEXU)Wm?aO!1Y==ccfO2u(5<&UsoL z!i8w0oVY}uglqiW$QtT0gSNFB4zya*tl~s;?6mKqZ$*EiIg!1YT;u-AB*a3}t|v@l z{GG8h7yp!=egEgK{;JlQjFp zMQ~oJ6ItD;E1q_410zw@4>rc?#-H9Z6vLbQak{79A2D;YdU!bN%D(E~(XzKT-=TU_ z{ZP~1Jpbn?sp3q|@$f6NPPPHFg50C7Hp7|aV9)4=r*hcVhnjadLMU$&9C+Idf8o$~7c09rP8A+d~3S4Qgm(ep$Po>v#WD3`qKe;`j!i|}g)5^M+=fF+mfNK?d zphO3$9oe`zA(-2CC(wsUT5D3tP>HR1?I%Gi|0uD=?cNZZtF@!=9cg=MpZTkp$m&hE zGfQaNqH%L%bBuD*NN|aDPu;!_tni;ON$3t&k6Nt<8GSa2HsXvsXD-pnsLIs)@;&yu z>UhbkqQ~k-6B~~T2I6aDth9u*v(!f9DRky*#$RohSEVtqG8C(xsqW~Q)L0MIxoie* zmRRo1y!zQxjL|h#;13r=!WLNRohjLaZ+pbu$6ALRJDsz zr&3zJUCGBO{Tc%^CXIr;?dgQ_gZ*NZc9;{sQ-DBJ<*Gw16 z{0tg9b2{tg43k}FS#wr)x^K{xq=fK|Idg3t);<$3kxgAm=5_8r*E(<;IC#xm%EG4Q zSg*FQG3~ymI-yHYU0XG#Yu`NTI`!RN@x10ZOVzU^xrWU4=qvktRp`Wn;!g{~&32o{ zJ$>PtZJGl%tFzu-bl3g4^#{9zJF&A94Z6oIdjexN)y>;GwwurQpDgNcW=)E@P~uWF zds6%C*H)}O+BTbd_RfTTASI2{1=C5}`{_@SRjKt}xw$XR7MD#sv3&&?KBt#M)6X(; z5307D7Sci&)|#iT;(K+O8s!@a=6^I@9u_Y*UY&0cY!R67(|UUzo1K{qukHg#l-LyH z)i@D9GeNh~dwQt}zDXa!u@1VZB?oB`fo{55XUi(35S-&?a*Z56*h?$jxH+Y}*q?*H zvz}G|ZRZCq$fFvfhk3OA2qZiN3O)d>O_m8c?J-XH>?jda^W3vcJfvab#Jfn!`!6E%KHS^T|f;agkIjn1A(nEzV8CRLyXiUjAdj%kAXG}1O=0U zFo6~r`0#=6{o57=(}OU6_d`IS*JdE-f1i;BuIRr=;Da9XpDRXm5C|LiMFf0Y(;@#n zjR8x?_@@mT1@3_a6on)tfUBZ`y^)c%gQ<<9XFYxg&~d|7T-^Z#BBnuqz!C}%_W=6i zW=d*~YBJJX1~yjA`i3^IjF?@mY|(T;Jg!_o)5^$EAK_|cY3;z}%1izG2`-?G?q;Dz z{C>pIf|puN<|RVN#@-0Q!Tf~z2{j)M0)gPMH#Fu_5ElLKaNrj&wW*_{Ef))mi;D}h z%QI#hdlQzYFJ8Q0dBV!V%E|;h!Q|j(?Wpg{WbN?aKSchaBW&bgU~gvYXl7%LK-1NK zW#iSN@kI{v+n!y?~tgaClh$)fpd-VEu>&pvL=V!g5N$6)>{jf9Ak9J@7$afzMqA z*+Q-`5QyYNLRdh_6}&Zf!!~)vtMg~GtW?~xV!Vy!JJU&fD21^(M1%mwZg~E9Vho4gX-GxM=Q1Hk_ zLNFAL1pW7`#=phopZ4!wKgg^AOr!MmA4;Wu%)&~=W{6h^vk3{}|5t(UREbGBir)d<22QqweBfAOMb zH~V~6trJs6N>VlkpL|AK)YyFS5e4BNQ3gL+bVELO*=hKiQX#9tG&80r5*<1UF;bntqiLEGz$OVqjsacMPGzIKqs5b4bN^Z&dAbDe0ln@a z-7$mf56s17je7HUb_gH?u81IX~sEsH+qG;ZrXEe`r#^kxp7XSq4P(UxgEemg?HAe~WO|3sC7ka0jQ4O_97~FP2^950+5BPJ_?; zZ1&-mwXf?qZr?xk8g1wA&a&)|FUm?t#AqI=Uf#3bp|sCGa(RH^H@AUDvEV6 zzhC~|x{9PC#Bc7!`)B72>a~u!xL_vD50d)LJfu98NtFYAj;p%)P75_xC|o$5WDkvi zkYAubX~f$|S?%fZ0$Wq$+T_Qo+uCy?3X1o3qK#bWu<+J5>NLj~-qQW;zSM(-;8t&% z=2h%=!>?6=`u+|@EI#T}FVrulj1#VZ(w_9zIUTsIbq|GIoi!TF5W4-W6|t8T5{*cm zvcE7*z`gH<*Zh~Cm>#2ScZJ%GwgUE0@S2;>oQo?Y#J%HT zuV+ivKbdB^?-z;ST}9hqc=4yy>+oG%YH$YWPtW-qMc}LG{QQD%HWskMj)xf#6Z5dv z+Fm!o{v6SGk8oc6Qym8}JbVN6BjLIlS=gFZ_?>}vj1Baly|0fHZ?ZC7*+r_1tm>FLK%kO;xkv_Kd_ko2okT1EEQ+xI`u3_`e?Eg z=4Eu&D5MpiVM?dy{v-LTr?s!;g1s=8vo%u$|1%wINLr-&vrwnFbg~3CCzP~RdX&wI z2vz`vs$u<9kYyaTh3i6*3wO}lHF+BzK?AV<`qK9Pu?54IIw>a>Qx*b#b`_Ib=H%PA zH&Pi`3LL-R!QbJ3WVZfjn%5k)8KUfh+JVCb*(b&R<#JDOu^bm>b8Q{S_{%ftc%~%w zUZpSpc5y+aZ|dEn*@==lx{}7qQ5h>POC?`t39<)b5y^4!-i_kNn~v$@&wchyCM3C2 z$RAA;lftbNIbL@1WOq@{pHJgKcgC|Cc}_o@t`D<&@*oRQdM+3`K5ifDL-pGkI~E4T zOo+&c$8#QQXm7p1nFCFtfCRLtS-LxbK3RlnUw|5-^EhcBQgO>tl=0K}rX%uuKT#L_t z^H*yS`8H`%bAE(0q=|8UeV*0R(s%ZSL7rwV)l1Pr)JqkOM=ZR}FFWttx5x9`R4~FQ zX6XRdwc}%%ZD^eHh4HNI<=*?(d(?kngA=5W0>0yIn}@HkLw+1uS45}xogi-G43c)a zD{8fz7LU&*HIT(mksBH;l9oR@&f~hmPtvTWS1q??@5@6Y;3dgr>(TJrc`hVU!<`(wr$YP)`|Rs&}& zt0myRsCT~akg@c7KH11y*>4SiOh- zCZ%*DSE))KVG8)ECKXS+K#LGqSc>4vja$TH2 zK1UCog-e=*BG&lkwN31&DWCDRHiHW9iqCUTOW}HRt`Nn{xcdyTtJiK#_ql8(xhzi7 z>Abm`=25x~xfv+E-p4ab#=C20c;nl2oGiV4=7yWMCy5yllXoHaOsz0to>dWE9}dt- z3JlyoX=>r;uo&UAIH_Bg!59$XJ07V*1g*uCW@wrsOIHo%$p=ZST5DeWu*IR^Azgg` z>IEt7Ym5hbYLUr8Yi?fZ1Pcmdj=cBbrtOHos7J)nNE)w2AmhlF35_+BjwX}q;i--j zp0VZ=tK-6za?79iF4!$Q8gIuxDnGb4iiZikcc5qFd2uzdpCGGh-ibGAEqTJvrC|B9 zObz^PuXThBPTW1uOQ&^78DWpz+EX!>wA%lRYkIwQ&v8!XQfuPFAci1@AcHo|i)I4` z2}?cCpcD)JX3yH6ahZ8k43%2Pf8iPo#%6&m6}12|4Vnjr=KYc>>zyAkt1$b^HL7Y# z#i!EykH$eTrAZ2cjTCT_=qTCuBF)@hXi79&vyg2Z;ylp%APA$}^-ATWI^#io?@g;& zvEnyyK(_ZH$eG59MZc6Aeb?vIUO!Si>y;S^arA7j#!^+NCXLiNI|(dzIDhIQ_I-!) zX8aJL0o<~hwqB@g|J)A0IQlH&#pFgW2lCuqdwCn5+@G*65Gz1Hs4h6n23B>Gcl_Au zX`Sgvr6@(`!XLMVL7|E4h)F>+&IOf+LZZico7}qg*-G={#XRHWU1#TV%_ruyKkD|L z;`sJVXF0K)y}BZHJLxN#Wy^G)X#IdGGddQ($-&$8;RUaz&Pu9ze9E`FGskeXl$tVB z`U@cr?+V@D*$>O3chzLpJ0r^BMTFO_+!nKU{p@bx&iNVn4ix9-N2y*Jlog(Da8Fd3 z3^QTO-3^MIu5~EYGYriOv`+Kft4f_w@oF3|O49THAoOVvrH7kShA)_h{}=v{2&GS9 z2rdhnalCYy7u#HUM=;Urw#_xFJwfd%Z9>sA{cg>>OtZ)mpTEpB;_uHiKEnbJ! zLWfmHop!<9t6$coRZcP?Ra4p#swT`)%DpbNx|h4}p9**AF3@c;K?9!PU;RlO_mGRx z*ii1=2d`x}-wNw?rKZrl8cCYo=;qvD*^R0=oh;+v9t>^Pp&yT>lQUr|4;-B4DD7|U zw-Q@^M=;(yRT0a&*duFARn>wtW+-?BZVj(kS6y(o3M|wLFyep5qKl%GEX&)=lrxT4E0j175`ti zM2SJ-MUbVs10>#c67wDwRZ8pRfj|m9{k~b$gxj2iqjWMn>G~&@cCKF7=tfhYj-yG} zmPnK+N74{3S5j1%dg+cp#D#0&;rK^>3{6E0KPQXnii7tJ$4d$?@^DM17OK_bKbnGD zRYbfG##Y=p^*)jR_+Xymy)ZVMtNXgHSa}EYva{#$*draQ?Z6i8HyK&(HD!0#nG_+f zYsDafcd>o`Y<*l2w(3wQH_PUPO>;Vs+>eIQmF^%%b|~(1bdA@MG!}+NTAdw7X3uPi z5a2t6Mg|`560nyi1kVpog~u_^?-+}he^j(-+U}|2z9oYPQ~411E>_BAWAe3fopJk% zlcxZ%Y>rn7?dnWUV&dM8v$waeFl-C+s_K=;<}95wmdqbMqOeRqipJDj*&Oqjd)!kU z*M1oh5#1JCkA3W$0nUhU)=tcHkz-|5(ALhpG?*)yHZiFyBOlUPeeqWgPL0W&gi7DG zd8AV+g3h7qb`4hzH|}2nYWW~1_~@OrUAWe%_2n+Mrt7{8QOkY30*pCJ!~b=d z6a8B31G%e14YQHsHdhosEkez0WhHip`E}nbL*EPl%sVy2)ecrDIA@}mHjI;;5_*Q$ zB^(Ya9QB;?lgEpth~bYP=g)o>skZiG>ftc<)YlHSv$?oR1Kcdl>e=lnX%RfDqUr3~ zkLu^9^!k78S<$(Z46?K*mCH%RxQ}t18;Y%^d#!Ka-o;CR@4`T&X)uq4Gmvy%WFZ}K zR}n#QWn0<*(?{Rwdywon*&-0cyO=&3H&^o?zq82pkr#70rMw5P?Az^-x;XJ+dS{hH zP~YKJ$t>zyr$%3<9!cz7JvB%AaZo+B@NE|pJG*)@{Z~8x=yS6EUg_1D+6zO`7 zp5b@xkc-Wc`gBf0;vx&Mnp?9&kCeJqOo-q~Z= z8{}W^vnvq=jq2B)k-W~79j=o(qshNR8H)BT+ozc3Ri(otcgnqb^{2|V^OBiLVt{gk zWEz#@`T7iog1Vi~*0u1k=7*k0 z!a2j?QQu)cx)D~4duX^oMPSON*AXfA<@^;+s)UG%4&3Z#-))f?4e78$cdnMOTpr!wyM&_|@x348^=s z=m?RBYM|0vO;Bl=*&=|YxF!FJIlTiEqj#&WD|j3rZD}h;E7^d6eV+h@V06Z0JA`=!ykB;G-GEzo z0vrK6K_3BZ&v}(^bWtoCi`XJom+Vd@9ZW5_iN~xmD1Qq=N<(QoR+pQ|BYM6{HkO|T>9@{p-F>0p zZ->YRGA3ZTl^Y@!=y=5ffkIi#rTvkcKuLbQ$khZ2gdakBA{lh`hYKjcJHon_VaWoB zQE=mdPC@&k2P=k>58p{h>gq~HcORd@g^~1|TrQ`B zm0NCCs4X8O!5_xB3PvjyB~Kde0lccwMn(o?AMIYeSPkp?(d#YJ->4_8@bPoGOY)WV zOK@v73r=9j?W4}P*0i&c318Jl+=q@%a6dlR=pKv5w_q@b{ zo}-9ELn&XLN5-+;9p&hX!}Ob;Gm4O#kkAQzkMeqQ*a>NC(W@)+VY|SG9Y?fZU+o9; zoQ0+JHpSy_x~msoW)VJ>PekKHfNTo~O2NIbC6J^j&Wcl;Io?u3bwUxh;6*w7jLgk<_rXO>sz59|x%3hyNkY8O>-B@Q-I7kQ{W z)Vyk|RE$Z*4p8`*f9@D>F64gfyBgGD51t{hv2@ynw6$Wq+pn#s9FZ?t+BdeNL@%gC z418XF*q^c+%W1_|pHquP2oDb@G1pb79tF(@AUQF^Bj^v0=qx-`vPaH@TRx>?LPH^# za|1Ri?Y?1Tr6sy8vEgf59u)9nxt>dx>vxQdG1QO6D*zMrHHFPq9k|yz;)1Ej#xi3Y z;icLo+lyDRb>~hh?Shd{Gw8srZ7pqr>c$bVlO^>e7HR=;q(P`Xbv&3GL@;2SEHMNG zkq1m&xXiq=b`z3_vsFXR%~!!6kHh%Tkry%9)!N4MEq#+gNti?&8EpE^lmn32;0FLw zWS-3LRH9OdoN`jv%X~zhP-Sd_Tk%D!E1)q)lNi>pWC;L_XsLA2Mam^A00hOz*F{dC z3hs=6L5dDIj{XqI(lC9ciXkX3r|oW;PyZ!4)$+aPprD&8F4)%zoypn?0hy#|%6d)& zq^>?nTg};4S=8s;Ls)PEB3R2<73J@#vkS-f)Z$*^Wq=4aY)b8-5sd~6X^Vz76PgMk z9elOb#svi*k99bjxLQm=+P0)6wq94k^S&>F(Ket7pn|28R40VEwBlaT z{Vs9qmget-bwqH>iOh8g-sUHrAAl8oO0M|6#85Uw_EGwZ za=ZG1Gdm6sVlue(YgwiaP3_k;0bKSUOh*8^xD)&yWQT^}s`A4E1_&9Ez5BExx9ODV zZLUU43b^H=o}?JTa|`+09D4_Mh+mRZ?gR|6mD+{tYQp2`QF8tWOcX@(L-@Fsl>qqu!IjXewBqede4 zGCSCS0Ej#UcpoYtWx`KD?P$dG(OP7|+{;$Nt$ncq1T!+R={7}%dXgt9vmU?Tc?xd* zwlv}d=t2_Eg;MRhB@$AdG?f2Mnu*@AJQ#`5HfveyjAG5@*v=NVmXku-)?Xb`fT6R5 z2tz1^_(4mzD z{rtO-+T%nEqk|tz(S3$q7DEQx-FTmt`j|!gGrwAt}kG zrTnRX0h^cCw}|Ti)L{CpZ8`vDiYbfG0YC!)zRJg6&NFHCG8PG{7ii>oFSD@%I>t-B zt}ILWoJCsBSLXZ_AsBdm2abQACw+bMUHR%4l6!fq32!g@nJ8ms_&hzfzl!47EWYUi zypURxi!2;10~$aEp`n~&;w|_uy_XpXdR4DC3KSTLH#Mwv?QNzj)%kKfT9bi)!*D8b z^IB9wcC8-lZ|{0aHGQZN>xgL3j~ku~t+sBI=5y{$DHn-=tP-;Tifq`But6(Q@dg|& zOVfT}2cK+Q#r(D8uNz5nXqoJsKaw-EQTFuLBDS}xwdZx1yL10lPA>P^wi5Em$2Z+K z+%7y8Vt225`FjgqMaxz5LZC)z$j+a|8Alb=3tk-(*kU(&Dn_Iw8dX`zl5 zHBGya5Gxzo{jNY-&w|^}_89>;mF^5cLqs+}HtI1+*b(C8M-@YJdj;Cu501Ap7+>%n z>sdNVmrwB#IB*|5l*I~Abl8);59r;l@UfR5pf56@9Y)LJk0Jpan}q$tn-~Cj6jn0J z0J10(pwEPhRyZ9f^t|kB^?wd1WJyi=EC9XR0NP|Su2}yh*S8)4%gpW(F+fAY0Dx5k zj?%%%6c9Kg+#lNxz^Tt=`G+?ER4@hFY>9g}AvyrlI-~Dt6#!oqj}7fWZ;ny{c2d{h z!+p5`YUJ1iOMvMI8z`^eLMJ0=H(>il3T*(erXlaC%s;CZ0oK6-yz|2|@{|hV1W@C` zr}zwD54lU8xh=OMos zDL`Cf`hrx5QX+Ik+ZtFP8j^47AaYvu4A~eyD&58K+tFZSmydkq>-E-8_s&v16e8$p zAvJ)usuf_(D`c}`E%iv+q?imsJeb7p0n8HG+CdX8bH_-(X{*smdc6VQ^_MdG6(w6? z0GP!y){Pe-mkbK5&tnMZM!F7|fY8cK+}9st&flgZnu@!87664#On}Y+3@QdV()!4D zc~c1~)Y{|gxSi|oi2$POrl)rX^$1r;i*Ju_F-}?P3ozCAN{#~{yOw(ZQ=IfQ{(u#6 zBd0_O&d#|fo5kZPfRjLs*j(BW1xEY(eyft)d{p4ppLb9@4^XIQfZ3Qx%7|gy?lRmu z^_LdIrq?`x;K8a*hQ2yV{8*J#FyhPtslGn#Pej2NriY&a%GIj{f%R*#P6+o9>F2js z>>rV;#YV|Gvy{_dlff;)n7pA;VbrBbQD(Lls(=|3-`z#SROTbVu8ikQom`=oCz%=F z-Wr~B%`|qo-*!djt<8&!C}MW69^q%fzNv>&4--KIg>gilp&=6=uupObYwZk#%WyEa zRd4L2N_5Jq|00H;vV{PFO0{AtYslhp-Db|oe|DUJfeFh2me`m$w6q7CJRV-mucSy$ z-p`TujXEV@D zkoB31FHXbydRiUm0LPRi{`M4|O349E&9f<4;zw9&fTyxd-0n+h!o*b@t4#nK2jSj$ z_RM{7Un>DOFGY)xVgm5djpwd_H^4rt01})T9)6mJ$$0;8lUW4ApHx@YY`V~m_Xcbn zj(fwQu3)GkC?F(dI@o|Hajf&#Dj>)b^lNsg2LPL5I5M2vM5Un_MS}%2GsPkTZ|^g* z7n^`v$@FUV^fL+tG73#Nhd(35)$(zhBX9$#5VJlf26d^#A8!C$qmW35v+YU>cS*d| zr{uX|qOCefg4y$$tr?G+_4DmQ=9%4EfpHtPxx=G(X_ebiS>N41e95#9wG~1HKsd}J zhmW{&zqN=GTyUkVf2^AxL6wfub02*qe-}A{n;&~5B==G+`kTw+l*pm7rZ~m@&!@?= zpT5h_1u`w%-YsPJFW-DNbBGaVb#~S*N5S=+ChdCk1VH$!<#^ZM1@*;uNRFiF2%r7N z@}Dvmnu!K*u>O*%fLwQfd@R!)6sMD1i=S^enHqg5VMKs`xRZDL$vn+g=rap$Y@0*$&5Qc>RH>hhWh7N2ht)K z*}H;%F}BW_`du=|7ie+n-{>q`Fq7%_cmGCkZd>Voz>SzZJE<_a61)1DG|>HR;=_%i zB4WQ2^bU*(=k41c%bW|%%JbfRqL&_N=4OwZG5nZDkB#4_K4uLWRKRpqL_iWjTX+4H z%%9#pJYosIyQZk!9LS7dBq^N>>pAnSA}G47=7)vylkJh}tI6IeICmbD;mUfW{(89F ztLQa`&>p}5&jBvYuK|;j-e+=`3OaNAAM4xioceX^)u6ecz_j$BiLGxz`?DaBm{XAHgP!a<4oL`BBi0Sv4 zJhkd&kiiy4>m-2EZ3ThNeVd;L3<_*tV1lu3$I<|bK658V8=s>L&}KI$Ud{txXQ-{S zY6f^$Rwd3GEiO8MCLLqV>;Jb2dF>o!cV>R(=@33!s0_6j$M5Nvvw5e*Jag!jir$#O zVa-S;#Oz7UmdCp7NQW`&qhIa(?rDV!rFH9@Ja0gdT;K)VQq z&8GK{?sY8Rs`nN;Q?2wCNd53^2`JP80!_W4hICy!;4jqvTt3B)i-mQzT&xYeBtY`^ zxG;?It}i_RTA5nB)ANWYoSKm(J}V0=CIbz#dm54@O0IoQ@m+nCr|jKpv5B?lrNV?2 zTLfDT7Tk0w&W|13kG`LUs!j)TuuN6TdhZ^)bak0BCz5B6!4A=zyzh`wS748?nKL&) z9_e#Ba}E6eC+DNs@48G={C7#Lv>tHI?GsPg{!F~3%5>=R^Xu>GbGj7dTsI-o=CV(2 zrCI0s!<_v$k)_Ua>6Z2D_HMngn=?&<4)KZoui!BB#_^CQ({~$Z@{@$eNyB4)w|$aV z*QkeV zr`*4|4Nt^}J6@aeKpKIx?NA}j2%A<913OSq;&o~-wL5r+Oue>wnll>)J#O@(mUACUEbPyCStKqThn+a literal 0 HcmV?d00001 diff --git a/docs/public/screenshots/slider-standard.png b/docs/public/screenshots/slider-standard.png new file mode 100644 index 0000000000000000000000000000000000000000..6210d435b3997b76e8e7747ffdbf72b71b24eabc GIT binary patch literal 21269 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 literal 0 HcmV?d00001 diff --git a/docs/scripts/generate-rspress-content.ts b/docs/scripts/generate-rspress-content.ts index 1c9accd93a..1986c94669 100644 --- a/docs/scripts/generate-rspress-content.ts +++ b/docs/scripts/generate-rspress-content.ts @@ -107,9 +107,18 @@ const createNav = (version: string) => { ]; }; +// `pages` describes the current version, so components added after 5.x have no +// pages under `docs/5.x` and have to be left out of its sidebar. The mirror of +// the `HelperText` case below, which 5.x has and the current version dropped. +const componentsAddedAfter5x = new Set(['Slider']); + const getVersionComponentOrder = (version: string): MetaEntry[] => { - const fromConfig = Object.entries(componentDocsConfig.pages).map( - ([entryName, value]) => { + const fromConfig = Object.entries(componentDocsConfig.pages) + .filter( + ([entryName]) => + version !== '5.x' || !componentsAddedAfter5x.has(entryName) + ) + .map(([entryName, value]) => { if (typeof value === 'string') { return entryName; } @@ -121,8 +130,7 @@ const getVersionComponentOrder = (version: string): MetaEntry[] => { collapsible: true, collapsed: false, }; - } - ); + }); if (version !== '5.x') { return fromConfig; diff --git a/docs/src/data/componentDocs6x.json b/docs/src/data/componentDocs6x.json index 56b356912b..b7aa690352 100644 --- a/docs/src/data/componentDocs6x.json +++ b/docs/src/data/componentDocs6x.json @@ -10886,6 +10886,161 @@ "src/components/SegmentedButtons/SegmentedButtons.tsx" ] }, + "Slider/Slider": { + "filepath": "Slider/Slider.tsx", + "title": "Slider", + "description": "Material 3 slider for selecting a value from a range.\nSupports standard, centered, and range variants.\n\n## Usage\n```js\nimport * as React from 'react';\nimport { Slider } from 'react-native-paper';\n\nconst Example = () => {\n const [value, setValue] = React.useState(50);\n return ;\n};\n```\n\n`Slider.Centered` fills outwards from the midpoint, and `Slider.Range` takes a\n`[start, end]` tuple and shows two handles:\n\n```js\nconst [range, setRange] = React.useState([20, 75]);\nreturn ;\n```\n\n## Variants and `value`\n`variant` decides the type of `value` and of the three change callbacks, so\nnone of them can appear in the props table below:\n\n| `variant` | `value` | callbacks receive |\n| --- | --- | --- |\n| `'standard'` (default) | `number` | `number` |\n| `'centered'` | `number` | `number` |\n| `'range'` | `[number, number]` | `[number, number]` |\n\n`value` is required; `onValueChange`, `onSlidingStart` and `onSlidingComplete`\nare optional. `Slider.Centered` and `Slider.Range` set `variant` for you, so\nyou never pass it directly with those.\n\n## Theming\nCustomize by overriding these `theme.colors` roles:\n- `primary`: active track and handles. Override per instance with `color`\n- `secondaryContainer`: inactive track\n- `onPrimary`: stop indicators sitting on the active track\n- `onSecondaryContainer`: stop indicators sitting on the inactive track\n- `inverseSurface` / `inverseOnSurface`: value indicator background / label\n- `onSurface`: every element while `disabled`, at 38% opacity, or 12% for the\n inactive track", + "link": "slider", + "data": { + "description": "Material 3 slider for selecting a value from a range.\nSupports standard, centered, and range variants.\n\n## Usage\n```js\nimport * as React from 'react';\nimport { Slider } from 'react-native-paper';\n\nconst Example = () => {\n const [value, setValue] = React.useState(50);\n return ;\n};\n```\n\n`Slider.Centered` fills outwards from the midpoint, and `Slider.Range` takes a\n`[start, end]` tuple and shows two handles:\n\n```js\nconst [range, setRange] = React.useState([20, 75]);\nreturn ;\n```\n\n## Variants and `value`\n`variant` decides the type of `value` and of the three change callbacks, so\nnone of them can appear in the props table below:\n\n| `variant` | `value` | callbacks receive |\n| --- | --- | --- |\n| `'standard'` (default) | `number` | `number` |\n| `'centered'` | `number` | `number` |\n| `'range'` | `[number, number]` | `[number, number]` |\n\n`value` is required; `onValueChange`, `onSlidingStart` and `onSlidingComplete`\nare optional. `Slider.Centered` and `Slider.Range` set `variant` for you, so\nyou never pass it directly with those.\n\n## Theming\nCustomize by overriding these `theme.colors` roles:\n- `primary`: active track and handles. Override per instance with `color`\n- `secondaryContainer`: inactive track\n- `onPrimary`: stop indicators sitting on the active track\n- `onSecondaryContainer`: stop indicators sitting on the inactive track\n- `inverseSurface` / `inverseOnSurface`: value indicator background / label\n- `onSurface`: every element while `disabled`, at 38% opacity, or 12% for the\n inactive track", + "displayName": "Slider", + "methods": [], + "statics": [], + "props": { + "min": { + "required": false, + "tsType": { + "name": "number" + }, + "description": "Lowest selectable value. Defaults to `0`." + }, + "max": { + "required": false, + "tsType": { + "name": "number" + }, + "description": "Highest selectable value. Defaults to `100`." + }, + "step": { + "required": false, + "tsType": { + "name": "number" + }, + "description": "Interval the value snaps to while dragging. `0`, the default, slides\ncontinuously. Also sets the spacing of the intermediate stop indicators\ndrawn when `showStops` is enabled." + }, + "size": { + "required": false, + "tsType": { + "name": "SliderSize" + }, + "description": "Track and handle dimensions, from `xs` to `xl`. Defaults to `s`. Only `m`,\n`l` and `xl` are tall enough to show an `icon`." + }, + "orientation": { + "required": false, + "tsType": { + "name": "union", + "raw": "'horizontal' | 'vertical'", + "elements": [ + { + "name": "literal", + "value": "'horizontal'" + }, + { + "name": "literal", + "value": "'vertical'" + } + ] + }, + "description": "Axis the slider runs along. Defaults to `horizontal`. A `vertical` slider\ngrows upwards from `min` and needs a height from its container or `style`,\nsince it has no intrinsic length." + }, + "disabled": { + "required": false, + "tsType": { + "name": "boolean" + }, + "description": "Whether the slider ignores touches and dims its colors. Defaults to `false`." + }, + "icon": { + "required": false, + "tsType": { + "name": "IconSource" + }, + "description": "Icon inset into the leading end of the active track. Ignored at sizes `xs`\nand `s`, which are too short to fit one, and while `disabled`." + }, + "showStops": { + "required": false, + "tsType": { + "name": "boolean" + }, + "description": "Whether to mark each `step` with a stop indicator. Requires `step` to be\ngreater than `0`. The indicators at `min` and `max` are always drawn." + }, + "showValueIndicator": { + "required": false, + "tsType": { + "name": "boolean" + }, + "description": "Whether to show a bubble above the handle with the current value while\ndragging. Defaults to `false`." + }, + "valueIndicatorLabel": { + "required": false, + "tsType": { + "name": "signature", + "type": "function", + "raw": "(v: number) => string", + "signature": { + "arguments": [ + { + "name": "v", + "type": { + "name": "number" + } + } + ], + "return": { + "name": "string" + } + } + }, + "description": "Formats the text inside the value indicator. Receives the value being\ndragged and defaults to rounding it to a whole number." + }, + "color": { + "required": false, + "tsType": { + "name": "ColorValue" + }, + "description": "Overrides the active track and handle color for this instance, in place of\nthe theme's `primary` role." + }, + "style": { + "required": false, + "tsType": { + "name": "StyleProp", + "elements": [ + { + "name": "ViewStyle" + } + ], + "raw": "StyleProp" + }, + "description": "Style for sizing and positioning the slider. Thickness comes from `size`,\nso only the length along the track is yours to set. A `height` when\n`orientation` is `vertical`, a `width` or `flex` when it is `horizontal`." + }, + "testID": { + "required": false, + "tsType": { + "name": "string" + }, + "description": "testID to be used on tests." + }, + "theme": { + "required": false, + "tsType": { + "name": "ThemeProp" + }, + "description": "" + }, + "accessibilityLabel": { + "required": false, + "tsType": { + "name": "string" + }, + "description": "Label read out by the screen reader in place of the slider's value." + } + } + }, + "type": "component", + "dependencies": [ + "src/components/Slider/Slider.tsx" + ] + }, "Snackbar": { "filepath": "Snackbar.tsx", "title": "Snackbar", diff --git a/docs/src/data/screenshots.ts b/docs/src/data/screenshots.ts index 92bf8f2788..25a730b37b 100644 --- a/docs/src/data/screenshots.ts +++ b/docs/src/data/screenshots.ts @@ -133,6 +133,12 @@ export const screenshots = { 'single select': 'screenshots/segmented-button-single-select.png', multiselect: 'screenshots/segmented-button-multi-select.png', }, + Slider: { + standard: 'screenshots/slider-standard.png', + centered: 'screenshots/slider-centered.png', + range: 'screenshots/slider-range.png', + vertical: 'screenshots/slider-vertical.png', + }, Snackbar: 'screenshots/snackbar.gif', Surface: { elevated: 'screenshots/surface-elevated-full-width.png',