UNPKG

@base-ui-components/react

Version:

Base UI is a library of headless ('unstyled') React components and low-level hooks. You gain complete control over your app's CSS and accessibility features.

216 lines (214 loc) 7.69 kB
'use client'; import * as React from 'react'; import { formatNumber } from '../../utils/formatNumber.js'; import { mergeReactProps } from '../../utils/mergeReactProps.js'; import { useEnhancedEffect } from '../../utils/useEnhancedEffect.js'; import { useForkRef } from '../../utils/useForkRef.js'; import { useBaseUiId } from '../../utils/useBaseUiId.js'; import { visuallyHidden } from '../../utils/visuallyHidden.js'; import { useCompositeListItem } from '../../composite/list/useCompositeListItem.js'; import { useFieldControlValidation } from '../../field/control/useFieldControlValidation.js'; import { useFieldRootContext } from '../../field/root/FieldRootContext.js'; import { getSliderValue } from '../utils/getSliderValue.js'; function getNewValue(thumbValue, step, direction, min, max) { return direction === 1 ? Math.min(thumbValue + step, max) : Math.max(thumbValue - step, min); } function getDefaultAriaValueText(values, index, format) { if (index < 0) { return undefined; } if (values.length === 2) { if (index === 0) { return `${formatNumber(values[index], [], format)} start range`; } return `${formatNumber(values[index], [], format)} end range`; } return format ? formatNumber(values[index], [], format) : undefined; } export function useSliderThumb(parameters) { const { active: activeIndex, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledby, 'aria-valuetext': ariaValuetext, changeValue, direction, disabled, format, getAriaLabel, getAriaValueText, id: idParam, inputId: inputIdParam, largeStep, max, min, minStepsBetweenValues, name, orientation, percentageValues, registerInputId, rootRef: externalRef, step, tabIndex, values: sliderValues } = parameters; const { setTouched } = useFieldRootContext(); const { getInputValidationProps, inputRef: inputValidationRef, commitValidation } = useFieldControlValidation(); const thumbId = useBaseUiId(idParam); const thumbRef = React.useRef(null); const inputRef = React.useRef(null); const mergedInputRef = useForkRef(inputRef, inputValidationRef); const { ref: listItemRef, index } = useCompositeListItem(); const mergedThumbRef = useForkRef(externalRef, listItemRef, thumbRef); const inputId = useBaseUiId(inputIdParam); useEnhancedEffect(() => { const { deregister } = registerInputId(index, inputId); return () => { deregister(index); }; }, [index, inputId, registerInputId]); const thumbValue = sliderValues[index]; // for SSR, don't wait for the index if there's only one thumb const percent = percentageValues.length === 1 ? percentageValues[0] : percentageValues[index]; const isRtl = direction === 'rtl'; const getThumbStyle = React.useCallback(() => { const isVertical = orientation === 'vertical'; if (!Number.isFinite(percent)) { return visuallyHidden; } return { position: 'absolute', [{ horizontal: 'insetInlineStart', vertical: 'bottom' }[orientation]]: `${percent}%`, [isVertical ? 'left' : 'top']: '50%', transform: `translate(${(isVertical || !isRtl ? -1 : 1) * 50}%, ${(isVertical ? 1 : -1) * 50}%)`, // So the non active thumb doesn't show its label on hover. pointerEvents: activeIndex !== -1 && activeIndex !== index ? 'none' : undefined, zIndex: activeIndex === index ? 1 : undefined }; }, [activeIndex, isRtl, orientation, percent, index]); const getRootProps = React.useCallback((externalProps = {}) => { return mergeReactProps(externalProps, { 'data-index': index, id: thumbId, onBlur() { if (!thumbRef.current) { return; } setTouched(true); commitValidation(getSliderValue({ valueInput: thumbValue, min, max, index, range: sliderValues.length > 1, values: sliderValues })); }, onKeyDown(event) { let newValue = null; const isRange = sliderValues.length > 1; switch (event.key) { case 'ArrowUp': newValue = getNewValue(thumbValue, event.shiftKey ? largeStep : step, 1, min, max); break; case 'ArrowRight': newValue = getNewValue(thumbValue, event.shiftKey ? largeStep : step, isRtl ? -1 : 1, min, max); break; case 'ArrowDown': newValue = getNewValue(thumbValue, event.shiftKey ? largeStep : step, -1, min, max); break; case 'ArrowLeft': newValue = getNewValue(thumbValue, event.shiftKey ? largeStep : step, isRtl ? 1 : -1, min, max); break; case 'PageUp': newValue = getNewValue(thumbValue, largeStep, 1, min, max); break; case 'PageDown': newValue = getNewValue(thumbValue, largeStep, -1, min, max); break; case 'End': newValue = max; if (isRange) { newValue = Number.isFinite(sliderValues[index + 1]) ? sliderValues[index + 1] - step * minStepsBetweenValues : max; } break; case 'Home': newValue = min; if (isRange) { newValue = Number.isFinite(sliderValues[index - 1]) ? sliderValues[index - 1] + step * minStepsBetweenValues : min; } break; default: break; } if (newValue !== null) { changeValue(newValue, index, event); event.preventDefault(); } }, ref: mergedThumbRef, style: { ...getThumbStyle() }, tabIndex: tabIndex ?? (disabled ? undefined : 0) }); }, [changeValue, commitValidation, disabled, getThumbStyle, index, isRtl, largeStep, max, mergedThumbRef, min, minStepsBetweenValues, setTouched, sliderValues, step, tabIndex, thumbId, thumbValue]); const getThumbInputProps = React.useCallback((externalProps = {}) => { let cssWritingMode; if (orientation === 'vertical') { cssWritingMode = isRtl ? 'vertical-rl' : 'vertical-lr'; } return mergeReactProps(getInputValidationProps(externalProps), { 'aria-label': getAriaLabel ? getAriaLabel(index) : ariaLabel, 'aria-labelledby': ariaLabelledby, 'aria-orientation': orientation, 'aria-valuemax': max, 'aria-valuemin': min, 'aria-valuenow': thumbValue, 'aria-valuetext': getAriaValueText ? getAriaValueText(formatNumber(thumbValue, [], format), thumbValue, index) : ariaValuetext ?? getDefaultAriaValueText(sliderValues, index, format), 'data-index': index, disabled, id: inputId, max, min, name, onChange(event) { // @ts-ignore changeValue(event.target.valueAsNumber, index, event); }, ref: mergedInputRef, step, style: { ...visuallyHidden, direction: isRtl ? 'rtl' : 'ltr', // So that VoiceOver's focus indicator matches the thumb's dimensions width: '100%', height: '100%', writingMode: cssWritingMode }, tabIndex: -1, type: 'range', value: thumbValue ?? '' }); }, [ariaLabel, ariaLabelledby, ariaValuetext, changeValue, disabled, format, getAriaLabel, getAriaValueText, getInputValidationProps, index, isRtl, max, mergedInputRef, min, name, orientation, sliderValues, step, inputId, thumbValue]); return React.useMemo(() => ({ getRootProps, getThumbInputProps, disabled, index }), [getRootProps, getThumbInputProps, disabled, index]); }