import * as React from 'react' import VisuallyHidden from '@accessible/visually-hidden' import useMergedRef from '@react-hook/merged-ref' import useLayoutEffect from '@react-hook/passive-layout-effect' import useChange from '@react-hook/change' import {raf, caf} from '@essentials/raf' export interface SliderContextValue { incr: (by?: number) => void decr: (by?: number) => void set: (value: number) => void value: number min: number max: number step: number focused: boolean disabled: boolean orientation: 'horizontal' | 'vertical' inputRef: React.MutableRefObject } const noop = () => {} export const SliderContext = React.createContext({ incr: noop, decr: noop, set: noop, value: 0, min: 0, max: 0, step: 0, focused: false, disabled: false, orientation: 'horizontal', inputRef: {current: null}, }), useSlider = () => React.useContext(SliderContext), useValue = () => useSlider().value, useOrientation = () => useSlider().orientation, useFocused = () => useSlider().focused, useDisabled = () => useSlider().disabled, useControls = () => { const {incr, decr, set} = useSlider() return {incr, decr, set} } export interface SliderProps { min?: number max?: number step?: number value?: number defaultValue?: number disabled?: boolean orientation?: 'horizontal' | 'vertical' onChange?: (value: number) => any onFocus?: (event: React.FocusEvent) => any onBlur?: (event: React.FocusEvent) => any children?: | React.ReactElement | ((context: SliderContextValue) => React.ReactElement) [property: string]: any } const round = (value: number, step: number) => Math.round(value / step) * step export const Slider: React.FC = React.forwardRef< JSX.Element | React.ReactElement, SliderProps >( ( { min = 0, max = 100, step = 1, defaultValue = 50, value: controlledValue, disabled = false, orientation = 'horizontal', onChange = () => {}, onFocus, onBlur, children, ...props }, ref: any ) => { const [valueState, setValue] = React.useState( controlledValue ?? defaultValue ) const [focused, setFocused] = React.useState(false) const inputRef = React.useRef(null) const value = round( controlledValue === void 0 ? valueState : controlledValue, step ) if ( typeof process !== 'undefined' && process.env.NODE_ENV !== 'production' ) { if (min > max) { throw new Error( `[AccessibleSlider] min value must be less than the max value` ) } if (value > max) { throw new Error( `[AccessibleSlider] ${value} is greater than the max allowed: ${max}` ) } if (value < min) { throw new Error( `[AccessibleSlider] ${value} is less than the min allowed: ${min}` ) } } useChange(valueState, onChange) const context = React.useMemo( () => ({ value, incr: (by = step) => !disabled && setValue((current) => Math.min(round(current + by, step), max)), decr: (by = step) => !disabled && setValue((current) => Math.max(round(current - by, step), min)), set: (next: number) => !disabled && setValue(Math.max(Math.min(round(next, step), max), min)), focused, disabled, orientation, min, max, step, inputRef, }), [value, focused, disabled, orientation, min, max, step, inputRef] ) // @ts-ignore const realChildren = typeof children === 'function' ? children(context) : children return ( { const nextValue = Number(e.target.value) setValue(nextValue) }} onFocus={(e) => { setFocused(true) onFocus?.(e) }} onBlur={(e) => { setFocused(false) onBlur?.(e) }} ref={useMergedRef(ref, inputRef)} {...props} /> {React.isValidElement(realChildren) && React.cloneElement(realChildren, { 'aria-hidden': realChildren.props.hasOwnProperty('aria-hidden') ? realChildren.props['aria-hidden'] : 'true', })} ) } ) export interface ThumbProps { children: React.ReactElement | JSX.Element } export const Thumb: React.FC = ({children}) => { const orientation = useOrientation() const progress = useProgress() const props = children.props return React.cloneElement(children, { style: Object.assign( { pointerEvents: 'none', position: 'absolute', [orientation === 'horizontal' ? 'left' : 'bottom']: `${Math.round( progress * 100 )}%`, }, props.style ), }) } export const useProgress = (): number => { const {value, max, min} = useSlider() return (value - min) / (max - min) } export interface TrackProps { children: React.ReactElement | JSX.Element } export const Track: React.FC = ({children}) => { const mouseRef = useTrack() const props = children.props return React.cloneElement(children, { style: Object.assign( {userSelect: 'none', touchAction: 'none', position: 'relative'}, props.style ), ref: useMergedRef( // @ts-ignore children.ref, mouseRef ), }) } type MouseState = { x?: number y?: number elementWidth?: number elementHeight?: number isDown: boolean } const useTrack = () => { const {set, max, min, orientation, inputRef} = useSlider() const mouseRef = React.useRef(null) const [mouse, setMouse] = React.useState({isDown: false}) const prevIsDown = React.useRef(mouse.isDown) useLayoutEffect(() => { const current = mouseRef.current if (current) { const position = (e: MouseEvent | TouchEvent) => { const rect = current.getBoundingClientRect() let clientX, clientY if ('changedTouches' in e) { clientX = e.changedTouches[0].clientX clientY = e.changedTouches[0].clientY } else { clientX = e.clientX clientY = e.clientY } return { x: clientX - rect.left, y: rect.bottom - clientY, elementWidth: rect.width, elementHeight: rect.height, isDown: true, } } const onDown = (e: MouseEvent | TouchEvent) => setMouse((prev) => (prev.isDown ? prev : position(e))) const onUp = (e: MouseEvent | TouchEvent) => setMouse((prev) => !prev.isDown ? prev : Object.assign(position(e), {isDown: false}) ) let tick: number | undefined, ev: MouseEvent | TouchEvent const onMove = (e: MouseEvent | TouchEvent) => { ev = e // always use latest event despite ticks if (tick) return tick = raf(() => { setMouse((prev) => (!prev.isDown ? prev : position(ev))) tick = void 0 }) } const docEl = window current.addEventListener('mousedown', onDown) docEl.addEventListener('mouseup', onUp) current.addEventListener('touchstart', onDown) docEl.addEventListener('touchend', onUp) docEl.addEventListener('mousemove', onMove) docEl.addEventListener('touchmove', onMove) return () => { current.removeEventListener('mousedown', onDown) docEl.removeEventListener('mouseup', onUp) current.removeEventListener('touchstart', onDown) docEl.removeEventListener('touchend', onUp) docEl.removeEventListener('mousemove', onMove) docEl.removeEventListener('touchmove', onMove) /* istanbul ignore next */ tick && caf(tick) } } }, []) useLayoutEffect(() => { if ( mouse.x !== void 0 && mouse.y !== void 0 && mouse.elementWidth !== void 0 && mouse.elementHeight !== void 0 ) { const progress = orientation === 'horizontal' ? mouse.x / mouse.elementWidth : mouse.y / mouse.elementHeight set(min + progress * (max - min)) } if (prevIsDown.current && !mouse.isDown) inputRef.current?.focus() prevIsDown.current = mouse.isDown }, [inputRef, mouse, orientation, max, min, set]) return mouseRef } /* istanbul ignore next */ if (typeof process !== 'undefined' && process.env.NODE_ENV !== 'production') { Slider.displayName = 'AccessibleSlider' Thumb.displayName = 'Thumb' Track.displayName = 'Track' }