import { clamp, Mouse } from '@vev/utils'; import React, { useEffect, useMemo, useRef } from 'react'; import { useFormField } from '..'; import { FormFieldProps } from '../silke-form'; import { SilkeSliderPoint } from './silke-slider-point'; import { findClosestIndex, getNewValue } from './silke-slider-utils'; import styles from './silke-slider.module.scss'; import { SliderTrack } from './slider-track'; import { debounce } from 'lodash'; export type SilkeSliderProps = FormFieldProps & { className?: string; disabled?: boolean; trackClassName?: string; handleClassName?: string; style?: React.CSSProperties; trackStyle?: React.CSSProperties; handleStyle?: React.CSSProperties; /** disabling order will allow points to be dragged anywhere on the slide, no matter index in the value array */ disableOrder?: boolean; /** To highlight one of the dots as selected */ selected?: number; /** If onAdd will allow points to be added by clicking on the slider */ onTrackClick?: (e: React.MouseEvent, value: number) => void; onHandleClick?: (e: React.MouseEvent, index: number) => void; onRequestRemove?: (index: number) => boolean; debounceMs?: number; }; export function SilkeSlider(props: SilkeSliderProps) { const { disabled, disableOrder, trackClassName, trackStyle, handleClassName, handleStyle, value, className, style, selected, onChange, onHandleClick, onTrackClick, onRequestRemove, debounceMs = 0, } = useFormField(props); const ref = useRef(null); const pointRef = useRef([]); const [tmpValue, setTmpValue] = React.useState(undefined); const [dragging, setDragging] = React.useState(undefined); const lastDrag = useRef(0); if (dragging !== undefined) lastDrag.current = dragging; const valueList = typeof value === 'number' ? [value] : !value?.length ? [1] : (value as number[]); // Add memoized debounced onChange const debouncedOnChange = useMemo( () => debounce((newValue: number | number[]) => onChange?.(newValue), debounceMs), [onChange], ); // Cleanup debounce on unmount useEffect(() => debouncedOnChange.flush, [debouncedOnChange]); useEffect(() => { const el = ref.current; if (dragging === undefined || !el) return; const handleMouseMove = (e: MouseEvent) => { const { left, width, top } = el.getBoundingClientRect(); const dy = Math.abs(e.pageY - top); if (dy > 100 && onRequestRemove?.(dragging)) { return handleMouseUp(); } const pointValue = clamp((e.pageX - left) / width, 0, 1); const newValues = getNewValue( [...(tmpValue || valueList || [1])], dragging, pointValue, disableOrder, ); setTmpValue(newValues); debouncedOnChange(typeof value === 'number' ? newValues[0] : newValues); }; const handleMouseUp = () => { setDragging(undefined); setTmpValue(undefined); }; Mouse.on('mousemove', handleMouseMove); Mouse.on('mouseup', handleMouseUp); return () => { Mouse.off('mousemove', handleMouseMove); Mouse.off('mouseup', handleMouseUp); }; }, [dragging, disableOrder]); const handlePointDown = (e: React.MouseEvent, index: number) => { e.stopPropagation(); onHandleClick?.(e, index); setTmpValue(points); setDragging(index); }; const handleTrackDown = (e: React.MouseEvent) => { const { left, width } = e.currentTarget.getBoundingClientRect(); const pointValue = clamp((e.pageX - left) / width, 0, 1); if (onTrackClick) onTrackClick(e, pointValue); else { const closesIndex = findClosestIndex(points, pointValue); lastDrag.current = closesIndex; const newValues = getNewValue( [...(tmpValue || valueList || [1])], closesIndex, pointValue, disableOrder, ); setTmpValue(newValues); setDragging(closesIndex); debouncedOnChange?.(typeof value === 'number' ? newValues[0] : newValues); } // Focus the closest handle after a short delay in case a new point was added setTimeout(() => { const closesIndex = findClosestIndex(pointRef.current, pointValue); const handleEl = ref.current?.querySelector( `.${styles.handle}:nth-child(${closesIndex + 1})`, ) as HTMLElement; if (handleEl) handleEl.focus(); }, 300); }; const handleKeyDown = (e: React.KeyboardEvent) => { const index = lastDrag.current; if (e.key === 'Backspace' || e.key === 'Delete') { if (onRequestRemove?.(lastDrag.current)) { e.preventDefault(); e.stopPropagation(); } } if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') { e.preventDefault(); e.stopPropagation(); let step = e.key === 'ArrowRight' ? -0.01 : 0.01; if (e.shiftKey) step *= 10; const newValues = getNewValue( [...(tmpValue || valueList || [1])], index, points[index] - step, disableOrder, ); debouncedOnChange?.(typeof value === 'number' ? newValues[0] : newValues); } }; const points = (tmpValue || valueList).map((v) => clamp(v, 0, 1)); pointRef.current = points; const range = [points[0] || 0, points[points.length - 1] || 1] as [number, number]; let cl = styles.root; if (dragging !== undefined) cl += ` ${styles.dragging}`; if (className) cl += ` ${className}`; if (disabled) cl += ` ${styles.disabled}`; return (
{points.map((point, i) => ( handlePointDown(e, i)} onKeyDown={handleKeyDown} onFocus={() => (lastDrag.current = i)} /> ))}
); }