// forked from radix-ui import { composeRefs, useComposedRefs } from '@tamagui/compose-refs' import { isIos, isWeb } from '@tamagui/constants' import type { GestureReponderEvent, GetProps, SizeTokens, TamaguiElement, } from '@tamagui/core' import { getTokens, getVariableValue, styled, useConfiguration, useCreateShallowSetState, } from '@tamagui/core' import { getSize } from '@tamagui/get-token' import { clamp, composeEventHandlers, withStaticProperties } from '@tamagui/helpers' import type { SizableStackProps } from '@tamagui/stacks' import { ThemeableStack } from '@tamagui/stacks' import { useControllableState } from '@tamagui/use-controllable-state' import { useDirection } from '@tamagui/use-direction' import * as React from 'react' import type { View } from 'react-native' import { ARROW_KEYS, BACK_KEYS, PAGE_KEYS, SLIDER_NAME, SliderOrientationProvider, SliderProvider, useSliderContext, useSliderOrientationContext, } from './constants' import { convertValueToPercentage, getClosestValueIndex, getDecimalCount, getLabel, getNextSortedValues, getThumbInBoundsOffset, hasMinStepsBetweenValues, linearScale, roundValue, } from './helpers' import { SliderFrame, SliderImpl } from './SliderImpl' import type { ScopedProps, SliderContextValue, SliderHorizontalProps, SliderProps, SliderTrackProps, SliderVerticalProps, } from './types' const activeSliderMeasureListeners = new Set() // run an interval on web as using translate can move things at any moment // without triggering layout or intersection observers if (process.env.TAMAGUI_TARGET === 'web') { if (!process.env.TAMAGUI_DISABLE_SLIDER_INTERVAL) { setInterval?.( () => { activeSliderMeasureListeners.forEach((cb) => cb()) }, // really doesn't need to be super often 1000 ) } } /* ------------------------------------------------------------------------------------------------- * SliderHorizontal * -----------------------------------------------------------------------------------------------*/ const SliderHorizontal = React.forwardRef( (props: ScopedProps, forwardedRef) => { const { min, max, dir, onSlideStart, onSlideMove, onStepKeyDown, onSlideEnd, ...sliderProps } = props const direction = useDirection(dir) const isDirectionLTR = direction === 'ltr' const sliderRef = React.useRef(null) const [state, setState_] = React.useState(() => ({ size: 0, offset: 0 })) const setState = useCreateShallowSetState(setState_) function getValueFromPointer(pointerPosition: number) { const input: [number, number] = [0, state.size] const output: [number, number] = isDirectionLTR ? [min, max] : [max, min] const value = linearScale(input, output) return value(pointerPosition) } const measure = () => { sliderRef.current?.measure((_x, _y, width, _height, pageX, _pageY) => { setState({ size: width, offset: pageX, }) }) } if (process.env.TAMAGUI_TARGET === 'web') { useSliderMeasure(sliderRef, measure) } return ( { const value = getValueFromPointer(event.nativeEvent.locationX) if (value) { onSlideStart?.(value, target, event) } }} onSlideMove={(event) => { const value = getValueFromPointer(event.nativeEvent.pageX - state.offset) if (value) { onSlideMove?.(value, event) } }} onSlideEnd={(event) => { const value = getValueFromPointer(event.nativeEvent.pageX - state.offset) if (value) { onSlideEnd?.(event, value) } }} onStepKeyDown={(event) => { const isBackKey = BACK_KEYS[direction].includes(event.key) onStepKeyDown?.({ event, direction: isBackKey ? -1 : 1 }) }} /> ) } ) function useOnDebouncedWindowResize(callback: Function, amt = 200) { React.useEffect(() => { let last const onResize = () => { clearTimeout(last) last = setTimeout(callback, amt) } window.addEventListener('resize', onResize) return () => { clearTimeout(last) window.removeEventListener('resize', onResize) } }, []) } function useSliderMeasure(sliderRef: React.RefObject, measure: () => void) { useOnDebouncedWindowResize(measure) React.useEffect(() => { const node = sliderRef.current as any as HTMLDivElement if (!node) return let measureTm const debouncedMeasure = () => { clearTimeout(measureTm) measureTm = setTimeout(() => { measure() }, 200) } const io = new IntersectionObserver( (entries) => { debouncedMeasure() if (entries?.[0].isIntersecting) { activeSliderMeasureListeners.add(debouncedMeasure) } else { activeSliderMeasureListeners.delete(debouncedMeasure) } }, { root: null, rootMargin: '0px', threshold: [0, 0.5, 1.0], } ) io.observe(node) return () => { activeSliderMeasureListeners.delete(debouncedMeasure) io.disconnect() } }, []) } /* ------------------------------------------------------------------------------------------------- * SliderVertical * -----------------------------------------------------------------------------------------------*/ const SliderVertical = React.forwardRef( (props: ScopedProps, forwardedRef) => { const { min, max, onSlideStart, onSlideMove, onStepKeyDown, onSlideEnd, ...sliderProps } = props const [state, setState_] = React.useState(() => ({ size: 0, offset: 0 })) const setState = useCreateShallowSetState(setState_) const sliderRef = React.useRef(null) const configuration = useConfiguration() // these insets are insets passed from TamaguiProvider by useSafeAreaInsets() const insets = isIos && configuration.insets ? configuration.insets : { top: 0, bottom: 0 } function getValueFromPointer(pointerPosition: number) { const input: [number, number] = [0, state.size] const output: [number, number] = [max, min] const value = linearScale(input, output) return value(pointerPosition) } const measure = () => { sliderRef.current?.measure((_x, _y, _width, height, _pageX, pageY) => { setState({ size: height, offset: pageY + (isIos ? insets.top : 0), }) }) } if (process.env.TAMAGUI_TARGET === 'web') { useSliderMeasure(sliderRef, measure) } return ( { const value = getValueFromPointer(event.nativeEvent.locationY) if (value) { onSlideStart?.(value, target, event) } }} onSlideMove={(event) => { const value = getValueFromPointer(event.nativeEvent.pageY - state.offset) if (value) { onSlideMove?.(value, event) } }} onSlideEnd={(event) => { const value = getValueFromPointer(event.nativeEvent.pageY - state.offset) onSlideEnd?.(event, value) }} onStepKeyDown={(event) => { const isBackKey = BACK_KEYS.ltr.includes(event.key) onStepKeyDown?.({ event, direction: isBackKey ? -1 : 1 }) }} /> ) } ) /* ------------------------------------------------------------------------------------------------- * SliderTrack * -----------------------------------------------------------------------------------------------*/ type SliderTrackElement = TamaguiElement export const SliderTrackFrame = styled(SliderFrame, { name: 'Slider', variants: { unstyled: { false: { height: '100%', width: '100%', backgroundColor: '$background', position: 'relative', borderRadius: 100_000, overflow: 'hidden', }, }, } as const, defaultVariants: { unstyled: process.env.TAMAGUI_HEADLESS === '1', }, }) const SliderTrack = React.forwardRef( function SliderTrack(props: ScopedProps, forwardedRef) { const { __scopeSlider, ...trackProps } = props const context = useSliderContext(__scopeSlider) return ( ) } ) /* ------------------------------------------------------------------------------------------------- * SliderActive * -----------------------------------------------------------------------------------------------*/ export const SliderActiveFrame = styled(SliderFrame, { name: 'SliderActive', position: 'absolute', pointerEvents: 'box-none', variants: { unstyled: { false: { backgroundColor: '$background', borderRadius: 100_000, }, }, } as const, defaultVariants: { unstyled: process.env.TAMAGUI_HEADLESS === '1', }, }) type SliderActiveProps = GetProps const SliderActive = React.forwardRef(function SliderActive( props: ScopedProps, forwardedRef ) { const { __scopeSlider, ...rangeProps } = props const context = useSliderContext(__scopeSlider) const orientation = useSliderOrientationContext(__scopeSlider) const ref = React.useRef(null) const composedRefs = useComposedRefs(forwardedRef, ref) const valuesCount = context.values.length const percentages = context.values.map((value) => convertValueToPercentage(value, context.min, context.max) ) const offsetStart = valuesCount > 1 ? Math.min(...percentages) : 0 const offsetEnd = 100 - Math.max(...percentages) return ( ) }) /* ------------------------------------------------------------------------------------------------- * SliderThumb * -----------------------------------------------------------------------------------------------*/ // TODO make this customizable through tamagui // so we can accurately use it for estimatedSize below const getThumbSize = (val?: SizeTokens | number) => { const tokens = getTokens() const size = typeof val === 'number' ? val : getSize(tokens.size[val as any] as any, { shift: -1, }) return { width: size, height: size, minWidth: size, minHeight: size, } } export const SliderThumbFrame = styled(ThemeableStack, { name: 'SliderThumb', variants: { size: { '...size': getThumbSize, ':number': getThumbSize, }, unstyled: { false: { position: 'absolute', borderWidth: 2, borderColor: '$borderColor', backgroundColor: '$background', pressStyle: { backgroundColor: '$backgroundPress', borderColor: '$borderColorPress', }, hoverStyle: { backgroundColor: '$backgroundHover', borderColor: '$borderColorHover', }, focusVisibleStyle: { outlineStyle: 'solid', outlineWidth: 2, outlineColor: '$outlineColor', }, }, }, } as const, defaultVariants: { unstyled: process.env.TAMAGUI_HEADLESS === '1', }, }) export interface SliderThumbExtraProps { index?: number } export interface SliderThumbProps extends SizableStackProps, SliderThumbExtraProps {} const SliderThumb = SliderThumbFrame.styleable( function SliderThumb(props: ScopedProps, forwardedRef) { const { __scopeSlider, index = 0, circular, size: sizeProp, ...thumbProps } = props const context = useSliderContext(__scopeSlider) const orientation = useSliderOrientationContext(__scopeSlider) const [thumb, setThumb] = React.useState(null) const composedRefs = useComposedRefs(forwardedRef, setThumb as any) // We cast because index could be `-1` which would return undefined const value = context.values[index] as number | undefined const percent = value === undefined ? 0 : convertValueToPercentage(value, context.min, context.max) const label = getLabel(index, context.values.length) const sizeIn = sizeProp ?? context.size ?? '$true' const [size, setSize] = React.useState(() => { // for SSR const estimatedSize = getVariableValue(getThumbSize(sizeIn).width) as number return estimatedSize }) const thumbInBoundsOffset = size ? getThumbInBoundsOffset(size, percent, orientation.direction) : 0 React.useEffect(() => { if (thumb) { context.thumbs.set(thumb, index) return () => { context.thumbs.delete(thumb) } } }, [thumb, context.thumbs, index]) const positionalStyles = context.orientation === 'horizontal' ? { x: (thumbInBoundsOffset - size / 2) * orientation.direction, y: -size / 2, top: '50%', ...(size === 0 && { top: 'auto', bottom: 'auto', }), } : { x: -size / 2, y: size / 2, left: '50%', ...(size === 0 && { left: 'auto', right: 'auto', }), } return ( { setSize(e.nativeEvent.layout[orientation.sizeProp]) }} /** * There will be no value on initial render while we work out the index so we hide thumbs * without a value, otherwise SSR will render them in the wrong position before they * snap into the correct position during hydration which would be visually jarring for * slower connections. */ // style={value === undefined ? { display: 'none' } : props.style} onFocus={composeEventHandlers(props.onFocus, () => { context.valueIndexToChangeRef.current = index })} /> ) }, { staticConfig: { memo: true, }, } ) /* ------------------------------------------------------------------------------------------------- * Slider * -----------------------------------------------------------------------------------------------*/ const SliderComponent = React.forwardRef( (props: ScopedProps, forwardedRef) => { const { name, min = 0, max = 100, step = 1, orientation = 'horizontal', disabled = false, minStepsBetweenThumbs = 0, defaultValue = [min], value, onValueChange = () => {}, size: sizeProp, onSlideEnd, onSlideMove, onSlideStart, ...sliderProps } = props const sliderRef = React.useRef(null) const composedRefs = useComposedRefs(sliderRef, forwardedRef) const thumbRefs = React.useRef(new Map()) const valueIndexToChangeRef = React.useRef(0) const isHorizontal = orientation === 'horizontal' // We set this to true by default so that events bubble to forms without JS (SSR) // const isFormControl = // sliderRef.current instanceof HTMLElement ? Boolean(sliderRef.current.closest('form')) : true const [values = [], setValues] = useControllableState({ prop: value, defaultProp: defaultValue, transition: true, onChange: (value: number[]) => { updateThumbFocus(valueIndexToChangeRef.current) onValueChange(value) }, }) if (isWeb) { React.useEffect(() => { // @ts-ignore const node = sliderRef.current as HTMLElement if (!node) return const preventDefault = (e) => { e.preventDefault() } node.addEventListener('touchstart', preventDefault) return () => { node.removeEventListener('touchstart', preventDefault) } }, []) } function updateThumbFocus(focusIndex: number) { // Thumbs are not focusable on native if (!isWeb) return for (const [node, index] of thumbRefs.current.entries()) { if (index === focusIndex) { node.focus() return } } } function handleSlideMove(value: number, event: GestureReponderEvent) { updateValues(value, valueIndexToChangeRef.current) onSlideMove?.(event, value) } function updateValues(value: number, atIndex: number) { const decimalCount = getDecimalCount(step) const snapToStep = roundValue( Math.round((value - min) / step) * step + min, decimalCount ) const nextValue = clamp(snapToStep, [min, max]) setValues((prevValues = []) => { const nextValues = getNextSortedValues(prevValues, nextValue, atIndex) if (hasMinStepsBetweenValues(nextValues, minStepsBetweenThumbs * step)) { valueIndexToChangeRef.current = nextValues.indexOf(nextValue) return String(nextValues) === String(prevValues) ? prevValues : nextValues } return prevValues }) } const SliderOriented = isHorizontal ? SliderHorizontal : SliderVertical return ( { // when starting on the track, move it right away // when starting on thumb, dont jump until movemenet as it feels weird if (target !== 'thumb') { const closestIndex = getClosestValueIndex(values, value) updateValues(value, closestIndex) } onSlideStart?.(event, value, target) } } onSlideMove={disabled ? undefined : handleSlideMove} onHomeKeyDown={() => !disabled && updateValues(min, 0)} onEndKeyDown={() => !disabled && updateValues(max, values.length - 1)} onStepKeyDown={({ event, direction: stepDirection }) => { if (!disabled) { const isPageKey = PAGE_KEYS.includes(event.key) const isSkipKey = isPageKey || (event.shiftKey && ARROW_KEYS.includes(event.key)) const multiplier = isSkipKey ? 10 : 1 const atIndex = valueIndexToChangeRef.current const value = values[atIndex] const stepInDirection = step * multiplier * stepDirection updateValues(value + stepInDirection, atIndex) } }} /> {/* {isFormControl && values.map((value, index) => ( 1 ? '[]' : '') : undefined} value={value} /> ))} */} ) } ) const Slider = withStaticProperties(SliderComponent, { Track: SliderTrack, TrackActive: SliderActive, Thumb: SliderThumb, }) Slider.displayName = SLIDER_NAME /* -----------------------------------------------------------------------------------------------*/ // // TODO // const BubbleInput = (props: any) => { // const { value, ...inputProps } = props // const ref = React.useRef(null) // const prevValue = usePrevious(value) // // Bubble value change to parents (e.g form change event) // React.useEffect(() => { // const input = ref.current! // const inputProto = window.HTMLInputElement.prototype // const descriptor = Object.getOwnPropertyDescriptor(inputProto, 'value') as PropertyDescriptor // const setValue = descriptor.set // if (prevValue !== value && setValue) { // const event = new Event('input', { bubbles: true }) // setValue.call(input, value) // input.dispatchEvent(event) // } // }, [prevValue, value]) // /** // * We purposefully do not use `type="hidden"` here otherwise forms that // * wrap it will not be able to access its value via the FormData API. // * // * We purposefully do not add the `value` attribute here to allow the value // * to be set programatically and bubble to any parent form `onChange` event. // * Adding the `value` will cause React to consider the programatic // * dispatch a duplicate and it will get swallowed. // */ // return // } /* -----------------------------------------------------------------------------------------------*/ const Track = SliderTrack const Range = SliderActive const Thumb = SliderThumb export { Range, Slider, SliderThumb, SliderTrack, SliderActive, Thumb, // Track, } export type { SliderProps, SliderActiveProps, SliderTrackProps }