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.

274 lines (272 loc) 9.83 kB
'use client'; import * as React from 'react'; import { useMergedRefs } from '@base-ui-components/utils/useMergedRefs'; import { useIsoLayoutEffect } from '@base-ui-components/utils/useIsoLayoutEffect'; import { isReactVersionAtLeast } from '@base-ui-components/utils/reactVersion'; import { visuallyHidden } from '@base-ui-components/utils/visuallyHidden'; import { formatNumber } from "../../utils/formatNumber.js"; import { getStyleHookProps } from "../../utils/getStyleHookProps.js"; import { mergeProps } from "../../merge-props/index.js"; import { resolveClassName } from "../../utils/resolveClassName.js"; import { useBaseUiId } from "../../utils/useBaseUiId.js"; import { ARROW_DOWN, ARROW_UP, ARROW_RIGHT, ARROW_LEFT, HOME, END, COMPOSITE_KEYS } from "../../composite/composite.js"; import { useCompositeListItem } from "../../composite/list/useCompositeListItem.js"; import { useDirection } from "../../direction-provider/DirectionContext.js"; import { useFieldRootContext } from "../../field/root/FieldRootContext.js"; import { getSliderValue } from "../utils/getSliderValue.js"; import { roundValueToStep } from "../utils/roundValueToStep.js"; import { valueArrayToPercentages } from "../utils/valueArrayToPercentages.js"; import { useSliderRootContext } from "../root/SliderRootContext.js"; import { SliderThumbDataAttributes } from "./SliderThumbDataAttributes.js"; import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; const PAGE_UP = 'PageUp'; const PAGE_DOWN = 'PageDown'; const ALL_KEYS = new Set([ARROW_UP, ARROW_DOWN, ARROW_LEFT, ARROW_RIGHT, HOME, END, PAGE_UP, PAGE_DOWN]); function defaultRender(props, inputProps) { const { children, ...thumbProps } = props; return /*#__PURE__*/_jsxs("div", { ...thumbProps, children: [children, /*#__PURE__*/_jsx("input", { ...inputProps })] }); } function getDefaultAriaValueText(values, index, format, locale) { if (index < 0) { return undefined; } if (values.length === 2) { if (index === 0) { return `${formatNumber(values[index], locale, format)} start range`; } return `${formatNumber(values[index], locale, format)} end range`; } return format ? formatNumber(values[index], locale, format) : undefined; } function getNewValue(thumbValue, step, direction, min, max) { return direction === 1 ? Math.min(thumbValue + step, max) : Math.max(thumbValue - step, min); } /** * The draggable part of the the slider at the tip of the indicator. * Renders a `<div>` element. * * Documentation: [Base UI Slider](https://base-ui.com/react/components/slider) */ export const SliderThumb = /*#__PURE__*/React.forwardRef(function SliderThumb(componentProps, forwardedRef) { const { render: renderProp, className, disabled: disabledProp = false, getAriaLabel: getAriaLabelProp, getAriaValueText: getAriaValueTextProp, id: idProp, onBlur: onBlurProp, onFocus: onFocusProp, onKeyDown: onKeyDownProp, tabIndex: tabIndexProp, ...elementProps } = componentProps; const id = useBaseUiId(idProp); const inputId = `${id}-input`; const render = renderProp ?? defaultRender; const { active: activeIndex, disabled: contextDisabled, fieldControlValidation, formatOptionsRef, handleInputChange, labelId, largeStep, locale, max, min, minStepsBetweenValues, orientation, setActive, state, step, tabIndex: contextTabIndex, values: sliderValues } = useSliderRootContext(); let renderPropRef = null; if (typeof render !== 'function') { renderPropRef = isReactVersionAtLeast(19) ? render.props.ref : render.ref; } const disabled = disabledProp || contextDisabled; const externalTabIndex = tabIndexProp ?? contextTabIndex; const direction = useDirection(); const { controlId, setControlId, setTouched, setFocused, validationMode } = useFieldRootContext(); const thumbRef = React.useRef(null); useIsoLayoutEffect(() => { setControlId(inputId); return () => { setControlId(undefined); }; }, [controlId, inputId, setControlId]); const thumbMetadata = React.useMemo(() => ({ inputId }), [inputId]); const { ref: listItemRef, index } = useCompositeListItem({ metadata: thumbMetadata }); const mergedThumbRef = useMergedRefs(renderPropRef, forwardedRef, listItemRef, thumbRef); const thumbValue = sliderValues[index]; const percentageValues = valueArrayToPercentages(sliderValues.slice(), min, max); // 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}%)`, zIndex: activeIndex === index ? 1 : undefined }; }, [activeIndex, isRtl, orientation, percent, index]); const styleHooks = React.useMemo(() => getStyleHookProps({ disabled, dragging: index !== -1 && activeIndex === index }), [activeIndex, disabled, index]); const thumbProps = mergeProps({ [SliderThumbDataAttributes.index]: index, className: resolveClassName(className, state), id, onFocus() { setActive(index); setFocused(true); }, onBlur() { if (!thumbRef.current) { return; } setActive(-1); setTouched(true); setFocused(false); if (validationMode === 'onBlur') { fieldControlValidation.commitValidation(getSliderValue(thumbValue, index, min, max, sliderValues.length > 1, sliderValues)); } }, onKeyDown(event) { if (!ALL_KEYS.has(event.key)) { return; } if (COMPOSITE_KEYS.has(event.key)) { event.stopPropagation(); } let newValue = null; const isRange = sliderValues.length > 1; const roundedValue = roundValueToStep(thumbValue, step, min); switch (event.key) { case ARROW_UP: newValue = getNewValue(roundedValue, event.shiftKey ? largeStep : step, 1, min, max); break; case ARROW_RIGHT: newValue = getNewValue(roundedValue, event.shiftKey ? largeStep : step, isRtl ? -1 : 1, min, max); break; case ARROW_DOWN: newValue = getNewValue(roundedValue, event.shiftKey ? largeStep : step, -1, min, max); break; case ARROW_LEFT: newValue = getNewValue(roundedValue, event.shiftKey ? largeStep : step, isRtl ? 1 : -1, min, max); break; case PAGE_UP: newValue = getNewValue(roundedValue, largeStep, 1, min, max); break; case PAGE_DOWN: newValue = getNewValue(roundedValue, 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) { handleInputChange(newValue, index, event); event.preventDefault(); } }, ref: mergedThumbRef, style: getThumbStyle(), tabIndex: externalTabIndex ?? (disabled ? undefined : 0) }, styleHooks, elementProps); let cssWritingMode; if (orientation === 'vertical') { cssWritingMode = isRtl ? 'vertical-rl' : 'vertical-lr'; } const inputProps = mergeProps({ 'aria-label': typeof getAriaLabelProp === 'function' ? getAriaLabelProp(index) : elementProps['aria-label'], 'aria-labelledby': labelId, 'aria-orientation': orientation, 'aria-valuemax': max, 'aria-valuemin': min, 'aria-valuenow': thumbValue, 'aria-valuetext': typeof getAriaValueTextProp === 'function' ? getAriaValueTextProp(formatNumber(thumbValue, locale, formatOptionsRef.current ?? undefined), thumbValue, index) : elementProps['aria-valuetext'] || getDefaultAriaValueText(sliderValues, index, formatOptionsRef.current ?? undefined, locale), [SliderThumbDataAttributes.index]: index, disabled, id: inputId, max, min, onChange(event) { handleInputChange(event.target.valueAsNumber, index, event); }, step, style: { ...visuallyHidden, // So that VoiceOver's focus indicator matches the thumb's dimensions width: '100%', height: '100%', writingMode: cssWritingMode }, tabIndex: -1, type: 'range', value: thumbValue ?? '' }, fieldControlValidation.getValidationProps); if (typeof render === 'function') { return render(thumbProps, inputProps, state); } const { children: renderPropsChildren, ...otherRenderProps } = render.props; const children = thumbProps.children ?? renderPropsChildren; return /*#__PURE__*/React.cloneElement(render, mergeProps(thumbProps, { children: /*#__PURE__*/_jsxs(React.Fragment, { children: [typeof children === 'function' ? children() : children, /*#__PURE__*/_jsx("input", { ...inputProps })] }) }, otherRenderProps, { ref: thumbProps.ref })); }); if (process.env.NODE_ENV !== "production") SliderThumb.displayName = "SliderThumb";