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.

336 lines (334 loc) 12.7 kB
'use client'; import * as React from 'react'; import { useStableCallback } from '@base-ui-components/utils/useStableCallback'; import { useIsoLayoutEffect } from '@base-ui-components/utils/useIsoLayoutEffect'; import { useOnMount } from '@base-ui-components/utils/useOnMount'; import { useMergedRefs } from '@base-ui-components/utils/useMergedRefs'; import { visuallyHidden } from '@base-ui-components/utils/visuallyHidden'; import { formatNumber } from "../../utils/formatNumber.js"; import { mergeProps } from "../../merge-props/index.js"; import { useBaseUiId } from "../../utils/useBaseUiId.js"; import { useRenderElement } from "../../utils/useRenderElement.js"; import { valueToPercent } from "../../utils/valueToPercent.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 { useLabelableId } from "../../labelable-provider/useLabelableId.js"; import { getMidpoint } from "../utils/getMidpoint.js"; import { getSliderValue } from "../utils/getSliderValue.js"; import { roundValueToStep } from "../utils/roundValueToStep.js"; import { useSliderRootContext } from "../root/SliderRootContext.js"; import { sliderStateAttributesMapping } from "../root/stateAttributesMapping.js"; import { SliderThumbDataAttributes } from "./SliderThumbDataAttributes.js"; import { script as prehydrationScript } from "./prehydrationScript.min.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 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 and a nested `<input type="range">`. * * Documentation: [Base UI Slider](https://base-ui.com/react/components/slider) */ export const SliderThumb = /*#__PURE__*/React.forwardRef(function SliderThumb(componentProps, forwardedRef) { const { render, children: childrenProp, className, 'aria-describedby': ariaDescribedByProp, 'aria-label': ariaLabelProp, 'aria-labelledby': ariaLabelledByProp, disabled: disabledProp = false, getAriaLabel: getAriaLabelProp, getAriaValueText: getAriaValueTextProp, id: idProp, index: indexProp, inputRef: inputRefProp, onBlur: onBlurProp, onFocus: onFocusProp, onKeyDown: onKeyDownProp, tabIndex: tabIndexProp, ...elementProps } = componentProps; const id = useBaseUiId(idProp); const { active: activeIndex, lastUsedThumbIndex, controlRef, disabled: contextDisabled, validation, formatOptionsRef, handleInputChange, inset, labelId, largeStep, locale, max, min, minStepsBetweenValues, name, orientation, pressedInputRef, pressedThumbCenterOffsetRef, pressedThumbIndexRef, renderBeforeHydration, setActive, setIndicatorPosition, state, step, values: sliderValues } = useSliderRootContext(); const direction = useDirection(); const disabled = disabledProp || contextDisabled; const range = sliderValues.length > 1; const vertical = orientation === 'vertical'; const rtl = direction === 'rtl'; const { setTouched, setFocused, validationMode } = useFieldRootContext(); const thumbRef = React.useRef(null); const inputRef = React.useRef(null); const defaultInputId = useBaseUiId(); const labelableId = useLabelableId(); const inputId = range ? defaultInputId : labelableId; const thumbMetadata = React.useMemo(() => ({ inputId }), [inputId]); const { ref: listItemRef, index: compositeIndex } = useCompositeListItem({ metadata: thumbMetadata }); const index = !range ? 0 : indexProp ?? compositeIndex; const last = index === sliderValues.length - 1; const thumbValue = sliderValues[index]; const thumbValuePercent = valueToPercent(thumbValue, min, max); const [isMounted, setIsMounted] = React.useState(false); const [positionPercent, setPositionPercent] = React.useState(); useOnMount(() => setIsMounted(true)); const safeLastUsedThumbIndex = lastUsedThumbIndex >= 0 && lastUsedThumbIndex < sliderValues.length ? lastUsedThumbIndex : -1; const getInsetPosition = useStableCallback(() => { const control = controlRef.current; const thumb = thumbRef.current; if (!control || !thumb) { return; } const thumbRect = thumb.getBoundingClientRect(); const controlRect = control.getBoundingClientRect(); const side = vertical ? 'height' : 'width'; // the total travel distance adjusted to account for the thumb size const controlSize = controlRect[side] - thumbRect[side]; // px distance from the starting edge (inline-start or bottom) to the thumb center const thumbOffsetFromControlEdge = thumbRect[side] / 2 + controlSize * thumbValuePercent / 100; const nextPositionPercent = thumbOffsetFromControlEdge / controlRect[side] * 100; setPositionPercent(nextPositionPercent); if (index === 0) { setIndicatorPosition(prevPosition => [nextPositionPercent, prevPosition[1]]); } else if (last) { setIndicatorPosition(prevPosition => [prevPosition[0], nextPositionPercent]); } }); useIsoLayoutEffect(() => { if (inset) { queueMicrotask(getInsetPosition); } }, [getInsetPosition, inset]); useIsoLayoutEffect(() => { if (inset) { getInsetPosition(); } }, [getInsetPosition, inset, thumbValuePercent]); const getThumbStyle = React.useCallback(() => { const startEdge = vertical ? 'bottom' : 'insetInlineStart'; const crossOffsetProperty = vertical ? 'left' : 'top'; let zIndex; if (range) { if (activeIndex === index) { zIndex = 2; } else if (safeLastUsedThumbIndex === index) { zIndex = 1; } } else if (activeIndex === index) { zIndex = 1; } if (!inset) { if (!Number.isFinite(thumbValuePercent)) { return visuallyHidden; } return { position: 'absolute', [startEdge]: `${thumbValuePercent}%`, [crossOffsetProperty]: '50%', translate: `${(vertical || !rtl ? -1 : 1) * 50}% ${(vertical ? 1 : -1) * 50}%`, zIndex }; } return { ['--position']: `${positionPercent}%`, visibility: renderBeforeHydration && !isMounted || positionPercent === undefined ? 'hidden' : undefined, position: 'absolute', [startEdge]: 'var(--position)', [crossOffsetProperty]: '50%', translate: `${(vertical || !rtl ? -1 : 1) * 50}% ${(vertical ? 1 : -1) * 50}%`, zIndex }; }, [activeIndex, index, inset, isMounted, positionPercent, range, renderBeforeHydration, rtl, safeLastUsedThumbIndex, thumbValuePercent, vertical]); let cssWritingMode; if (orientation === 'vertical') { cssWritingMode = rtl ? 'vertical-rl' : 'vertical-lr'; } const inputProps = mergeProps({ 'aria-label': typeof getAriaLabelProp === 'function' ? getAriaLabelProp(index) : ariaLabelProp, 'aria-labelledby': ariaLabelledByProp ?? labelId, 'aria-describedby': ariaDescribedByProp, 'aria-orientation': orientation, 'aria-valuenow': thumbValue, 'aria-valuetext': typeof getAriaValueTextProp === 'function' ? getAriaValueTextProp(formatNumber(thumbValue, locale, formatOptionsRef.current ?? undefined), thumbValue, index) : getDefaultAriaValueText(sliderValues, index, formatOptionsRef.current ?? undefined, locale), disabled, id: inputId, max, min, name, onChange(event) { handleInputChange(event.target.valueAsNumber, index, event); }, onFocus() { setActive(index); setFocused(true); }, onBlur() { if (!thumbRef.current) { return; } setActive(-1); setTouched(true); setFocused(false); if (validationMode === 'onBlur') { validation.commit(getSliderValue(thumbValue, index, min, max, range, sliderValues)); } }, onKeyDown(event) { if (!ALL_KEYS.has(event.key)) { return; } if (COMPOSITE_KEYS.has(event.key)) { event.stopPropagation(); } let newValue = null; 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, rtl ? -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, rtl ? 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 (range) { newValue = Number.isFinite(sliderValues[index + 1]) ? sliderValues[index + 1] - step * minStepsBetweenValues : max; } break; case HOME: newValue = min; if (range) { newValue = Number.isFinite(sliderValues[index - 1]) ? sliderValues[index - 1] + step * minStepsBetweenValues : min; } break; default: break; } if (newValue !== null) { handleInputChange(newValue, index, event); event.preventDefault(); } }, step, style: { ...visuallyHidden, // So that VoiceOver's focus indicator matches the thumb's dimensions width: '100%', height: '100%', writingMode: cssWritingMode }, tabIndex: tabIndexProp ?? undefined, type: 'range', value: thumbValue ?? '' }, validation.getInputValidationProps); const mergedInputRef = useMergedRefs(inputRef, validation.inputRef, inputRefProp); const element = useRenderElement('div', componentProps, { state, ref: [forwardedRef, listItemRef, thumbRef], props: [{ [SliderThumbDataAttributes.index]: index, children: /*#__PURE__*/_jsxs(React.Fragment, { children: [childrenProp, /*#__PURE__*/_jsx("input", { ref: mergedInputRef, ...inputProps }), inset && !isMounted && renderBeforeHydration && // this must be rendered with the last thumb to ensure all // preceding thumbs are already rendered in the DOM last && /*#__PURE__*/_jsx("script", { // eslint-disable-next-line react/no-danger dangerouslySetInnerHTML: { __html: prehydrationScript }, suppressHydrationWarning: true })] }), id, onBlur: onBlurProp, onFocus: onFocusProp, onPointerDown(event) { pressedThumbIndexRef.current = index; if (thumbRef.current != null) { const axis = orientation === 'horizontal' ? 'x' : 'y'; const midpoint = getMidpoint(thumbRef.current); const offset = (orientation === 'horizontal' ? event.clientX : event.clientY) - midpoint[axis]; pressedThumbCenterOffsetRef.current = offset; } if (inputRef.current != null && pressedInputRef.current !== inputRef.current) { pressedInputRef.current = inputRef.current; } }, style: getThumbStyle(), suppressHydrationWarning: renderBeforeHydration || undefined, tabIndex: -1 }, elementProps], stateAttributesMapping: sliderStateAttributesMapping }); return element; }); if (process.env.NODE_ENV !== "production") SliderThumb.displayName = "SliderThumb";