import {AriaSliderThumbProps} from '@react-types/slider'; import {clamp, focusWithoutScrolling, mergeProps, useFormReset, useGlobalListeners} from '@react-aria/utils'; import {DOMAttributes, RefObject} from '@react-types/shared'; import {getSliderThumbId, sliderData} from './utils'; import React, {ChangeEvent, InputHTMLAttributes, LabelHTMLAttributes, useCallback, useEffect, useRef} from 'react'; import {SliderState} from '@react-stately/slider'; import {useFocusable} from '@react-aria/focus'; import {useKeyboard, useMove} from '@react-aria/interactions'; import {useLabel} from '@react-aria/label'; import {useLocale} from '@react-aria/i18n'; export interface SliderThumbAria { /** Props for the root thumb element; handles the dragging motion. */ thumbProps: DOMAttributes, /** Props for the visually hidden range input element. */ inputProps: InputHTMLAttributes, /** Props for the label element for this thumb (optional). */ labelProps: LabelHTMLAttributes, /** Whether this thumb is currently being dragged. */ isDragging: boolean, /** Whether the thumb is currently focused. */ isFocused: boolean, /** Whether the thumb is disabled. */ isDisabled: boolean } export interface AriaSliderThumbOptions extends AriaSliderThumbProps { /** A ref to the track element. */ trackRef: RefObject, /** A ref to the thumb input element. */ inputRef: RefObject } /** * Provides behavior and accessibility for a thumb of a slider component. * * @param opts Options for this Slider thumb. * @param state Slider state, created via `useSliderState`. */ export function useSliderThumb( opts: AriaSliderThumbOptions, state: SliderState ): SliderThumbAria { let { index = 0, isRequired, validationState, isInvalid, trackRef, inputRef, orientation = state.orientation, name } = opts; let isDisabled = opts.isDisabled || state.isDisabled; let isVertical = orientation === 'vertical'; let {direction} = useLocale(); let {addGlobalListener, removeGlobalListener} = useGlobalListeners(); let data = sliderData.get(state)!; const {labelProps, fieldProps} = useLabel({ ...opts, id: getSliderThumbId(state, index), 'aria-labelledby': `${data.id} ${opts['aria-labelledby'] ?? ''}`.trim() }); const value = state.values[index]; const focusInput = useCallback(() => { if (inputRef.current) { focusWithoutScrolling(inputRef.current); } }, [inputRef]); const isFocused = state.focusedThumb === index; useEffect(() => { if (isFocused) { focusInput(); } }, [isFocused, focusInput]); let reverseX = direction === 'rtl'; let currentPosition = useRef(null); let {keyboardProps} = useKeyboard({ onKeyDown(e) { let { getThumbMaxValue, getThumbMinValue, decrementThumb, incrementThumb, setThumbValue, setThumbDragging, pageSize } = state; // these are the cases that useMove or useSlider don't handle if (!/^(PageUp|PageDown|Home|End)$/.test(e.key)) { e.continuePropagation(); return; } // same handling as useMove, stopPropagation to prevent useSlider from handling the event as well. e.preventDefault(); // remember to set this so that onChangeEnd is fired setThumbDragging(index, true); switch (e.key) { case 'PageUp': incrementThumb(index, pageSize); break; case 'PageDown': decrementThumb(index, pageSize); break; case 'Home': setThumbValue(index, getThumbMinValue(index)); break; case 'End': setThumbValue(index, getThumbMaxValue(index)); break; } setThumbDragging(index, false); } }); let {moveProps} = useMove({ onMoveStart() { currentPosition.current = null; state.setThumbDragging(index, true); }, onMove({deltaX, deltaY, pointerType, shiftKey}) { const { getThumbPercent, setThumbPercent, decrementThumb, incrementThumb, step, pageSize } = state; if (!trackRef.current) { return; } let {width, height} = trackRef.current.getBoundingClientRect(); let size = isVertical ? height : width; if (currentPosition.current == null) { currentPosition.current = getThumbPercent(index) * size; } if (pointerType === 'keyboard') { if ((deltaX > 0 && reverseX) || (deltaX < 0 && !reverseX) || deltaY > 0) { decrementThumb(index, shiftKey ? pageSize : step); } else { incrementThumb(index, shiftKey ? pageSize : step); } } else { let delta = isVertical ? deltaY : deltaX; if (isVertical || reverseX) { delta = -delta; } currentPosition.current += delta; setThumbPercent(index, clamp(currentPosition.current / size, 0, 1)); } }, onMoveEnd() { state.setThumbDragging(index, false); } }); // Immediately register editability with the state state.setThumbEditable(index, !isDisabled); const {focusableProps} = useFocusable( mergeProps(opts, { onFocus: () => state.setFocusedThumb(index), onBlur: () => state.setFocusedThumb(undefined) }), inputRef ); let currentPointer = useRef(undefined); let onDown = (id?: number) => { focusInput(); currentPointer.current = id; state.setThumbDragging(index, true); addGlobalListener(window, 'mouseup', onUp, false); addGlobalListener(window, 'touchend', onUp, false); addGlobalListener(window, 'pointerup', onUp, false); }; let onUp = (e) => { let id = e.pointerId ?? e.changedTouches?.[0].identifier; if (id === currentPointer.current) { focusInput(); state.setThumbDragging(index, false); removeGlobalListener(window, 'mouseup', onUp, false); removeGlobalListener(window, 'touchend', onUp, false); removeGlobalListener(window, 'pointerup', onUp, false); } }; let thumbPosition = state.getThumbPercent(index); if (isVertical || direction === 'rtl') { thumbPosition = 1 - thumbPosition; } let interactions = !isDisabled ? mergeProps( keyboardProps, moveProps, { onMouseDown: (e: React.MouseEvent) => { if (e.button !== 0 || e.altKey || e.ctrlKey || e.metaKey) { return; } onDown(); }, onPointerDown: (e: React.PointerEvent) => { if (e.button !== 0 || e.altKey || e.ctrlKey || e.metaKey) { return; } onDown(e.pointerId); }, onTouchStart: (e: React.TouchEvent) => {onDown(e.changedTouches[0].identifier);} } ) : {}; useFormReset(inputRef, value, (v) => { state.setThumbValue(index, v); }); // We install mouse handlers for the drag motion on the thumb div, but // not the key handler for moving the thumb with the slider. Instead, // we focus the range input, and let the browser handle the keyboard // interactions; we then listen to input's onChange to update state. return { inputProps: mergeProps(focusableProps, fieldProps, { type: 'range', tabIndex: !isDisabled ? 0 : undefined, min: state.getThumbMinValue(index), max: state.getThumbMaxValue(index), step: state.step, value: value, name, disabled: isDisabled, 'aria-orientation': orientation, 'aria-valuetext': state.getThumbValueLabel(index), 'aria-required': isRequired || undefined, 'aria-invalid': isInvalid || validationState === 'invalid' || undefined, 'aria-errormessage': opts['aria-errormessage'], 'aria-describedby': [data['aria-describedby'], opts['aria-describedby']].filter(Boolean).join(' '), 'aria-details': [data['aria-details'], opts['aria-details']].filter(Boolean).join(' '), onChange: (e: ChangeEvent) => { state.setThumbValue(index, parseFloat(e.target.value)); } }), thumbProps: { ...interactions, style: { position: 'absolute', [isVertical ? 'top' : 'left']: `${thumbPosition * 100}%`, transform: 'translate(-50%, -50%)', touchAction: 'none' } }, labelProps, isDragging: state.isThumbDragging(index), isDisabled, isFocused }; }