UNPKG

@storybook/addon-ondevice-controls

Version:

Display storybook controls on your device.

211 lines (210 loc) 9.06 kB
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Animated, I18nManager, PanResponder, StyleSheet, View, } from 'react-native'; import { styled } from '@storybook/react-native-theming'; const THUMB_SIZE = 32; const THUMB_RADIUS = THUMB_SIZE / 2; const THUMB_HIT_SLOP = THUMB_SIZE; const SliderContainer = styled.View({ height: 48, justifyContent: 'center', paddingHorizontal: 10, }); const Track = styled.View(({ theme }) => ({ height: 4, borderRadius: 2, backgroundColor: theme.appBorderColor, position: 'relative', width: '100%', })); const Fill = styled.View(({ theme }) => ({ position: 'absolute', top: 0, height: 4, borderRadius: 2, backgroundColor: theme.color.positive, })); const Thumb = styled.View(({ theme }) => ({ width: THUMB_SIZE, height: THUMB_SIZE, borderRadius: THUMB_RADIUS, backgroundColor: theme.color.positive, borderWidth: 2, borderColor: theme.background.content, shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.25, shadowRadius: 3.84, elevation: 5, })); const clamp = (value, min, max) => Math.max(min, Math.min(max, value)); const Slider = ({ minimumValue = 0, maximumValue = 1, step, value = minimumValue, onValueChange, onSlidingComplete, disabled = false, style, }) => { const isRTL = I18nManager.isRTL; const initialValueRef = useRef(value ?? minimumValue); const [trackWidth, setTrackWidth] = useState(0); // eslint-disable-next-line react-hooks/refs const [displayValue, setDisplayValue] = useState(initialValueRef.current); // eslint-disable-next-line react-hooks/refs const translateX = useRef(new Animated.Value(0)).current; const currentTranslateRef = useRef(0); const startTranslateRef = useRef(0); const isDraggingRef = useRef(false); const range = maximumValue - minimumValue || 1; const clampValue = useCallback((raw) => { let clampedValue = clamp(raw, minimumValue, maximumValue); if (step && step > 0) { const steps = Math.round((clampedValue - minimumValue) / step); clampedValue = minimumValue + steps * step; clampedValue = clamp(clampedValue, minimumValue, maximumValue); } return clampedValue; }, [minimumValue, maximumValue, step]); const valueToTranslate = useCallback((val, width) => { if (width <= 0) return 0; const clampedValue = clampValue(val); const ratio = (clampedValue - minimumValue) / range; const normalized = isRTL ? 1 - ratio : ratio; return normalized * width; }, [clampValue, minimumValue, range, isRTL]); const translateToValue = useCallback((translate, width) => { if (width <= 0) return clampValue(minimumValue); const ratio = clamp(translate / width, 0, 1); const normalized = isRTL ? 1 - ratio : ratio; const raw = minimumValue + normalized * range; return clampValue(raw); }, [clampValue, minimumValue, range, isRTL]); const clampTranslate = useCallback((translate, width) => clamp(translate, 0, width), []); const updateFromTranslate = useCallback((nextTranslate, width, notify) => { const clampedTranslate = clampTranslate(nextTranslate, width); const snappedValue = translateToValue(clampedTranslate, width); const snappedTranslate = valueToTranslate(snappedValue, width); translateX.setValue(snappedTranslate); currentTranslateRef.current = snappedTranslate; setDisplayValue(snappedValue); if (notify) { onValueChange?.(snappedValue); } }, [clampTranslate, translateToValue, valueToTranslate, translateX, onValueChange]); // Sync with external value when not dragging useEffect(() => { if (isDraggingRef.current) return; const clamped = clampValue(value ?? minimumValue); setDisplayValue(clamped); if (trackWidth > 0) { const translate = valueToTranslate(clamped, trackWidth); translateX.setValue(translate); currentTranslateRef.current = translate; } }, [value, trackWidth, clampValue, valueToTranslate, translateX, minimumValue]); const handlePanMove = useCallback((gestureState, width) => { const delta = isRTL ? -gestureState.dx : gestureState.dx; const nextTranslate = startTranslateRef.current + delta; updateFromTranslate(nextTranslate, width, true); }, [updateFromTranslate, isRTL]); const handlePanEnd = useCallback((width, shouldNotifyComplete) => { isDraggingRef.current = false; const snappedValue = translateToValue(currentTranslateRef.current, width); const snappedTranslate = valueToTranslate(snappedValue, width); translateX.setValue(snappedTranslate); currentTranslateRef.current = snappedTranslate; setDisplayValue(snappedValue); if (shouldNotifyComplete) { onValueChange?.(snappedValue); onSlidingComplete?.(snappedValue); } }, [translateToValue, valueToTranslate, translateX, onValueChange, onSlidingComplete]); const panResponder = useMemo(() => // eslint-disable-next-line react-hooks/refs PanResponder.create({ onStartShouldSetPanResponder: () => !disabled, onMoveShouldSetPanResponder: () => !disabled, onStartShouldSetPanResponderCapture: () => !disabled, onMoveShouldSetPanResponderCapture: () => !disabled, onPanResponderGrant: (evt) => { if (disabled || trackWidth <= 0) return; isDraggingRef.current = true; const rawTouchX = evt.nativeEvent.locationX - THUMB_RADIUS; const clampedTouch = clamp(rawTouchX, 0, trackWidth); const proposedTranslate = isRTL ? trackWidth - clampedTouch : clampedTouch; const currentTranslate = currentTranslateRef.current; const distanceFromThumb = Math.abs(proposedTranslate - currentTranslate); if (distanceFromThumb <= THUMB_HIT_SLOP) { // Treat as grabbing the existing thumb position — don't jump. startTranslateRef.current = currentTranslate; const currentValue = translateToValue(currentTranslate, trackWidth); setDisplayValue(currentValue); onValueChange?.(currentValue); } else { // Treat as tap on track: jump to that location before dragging. updateFromTranslate(proposedTranslate, trackWidth, true); startTranslateRef.current = currentTranslateRef.current; } }, onPanResponderMove: (_evt, gestureState) => { if (disabled || trackWidth <= 0) return; handlePanMove(gestureState, trackWidth); }, onPanResponderRelease: () => { if (trackWidth <= 0) return; handlePanEnd(trackWidth, true); }, onPanResponderTerminationRequest: () => false, onPanResponderTerminate: () => { if (trackWidth <= 0) return; handlePanEnd(trackWidth, true); }, }), [ disabled, trackWidth, handlePanMove, handlePanEnd, updateFromTranslate, translateToValue, onValueChange, isRTL, ]); const handleTrackLayout = (event) => { const { width } = event.nativeEvent.layout; const usableWidth = Math.max(width - THUMB_RADIUS * 2, 0); setTrackWidth(usableWidth); if (usableWidth > 0) { const translate = valueToTranslate(displayValue, usableWidth); translateX.setValue(translate); currentTranslateRef.current = translate; } }; const fillWidth = trackWidth > 0 ? ((displayValue - minimumValue) / range) * trackWidth : 0; const fillStyle = isRTL ? [styles.fillBase, { right: 0, width: fillWidth }] : [styles.fillBase, { left: 0, width: fillWidth }]; return (_jsx(SliderContainer, { style: style, children: _jsx(View, { style: styles.touchArea, onLayout: handleTrackLayout, ...panResponder.panHandlers, collapsable: false, children: _jsxs(Track, { children: [_jsx(Fill, { style: fillStyle }), _jsx(Animated.View, { style: [styles.thumbWrapper, { transform: [{ translateX }] }], children: _jsx(Thumb, {}) })] }) }) })); }; const styles = StyleSheet.create({ touchArea: { height: 48, justifyContent: 'center', width: '100%', paddingHorizontal: THUMB_RADIUS, }, thumbWrapper: { position: 'absolute', top: -THUMB_RADIUS, left: -THUMB_RADIUS, pointerEvents: 'none', }, fillBase: { position: 'absolute', top: 0, height: 4, borderRadius: 2, }, }); export default Slider;