// SPDX-License-Identifier: AGPL-3.0-or-later import styles from '@app/features/ui/components/Slider.module.css'; import { buildMarkerState, clamp, scaleValueEquidistant, snapValueToMarker, unscaleValueEquidistant, } from '@app/features/ui/components/slider/SliderMath'; import {SliderResetControls} from '@app/features/ui/components/slider/SliderResetControls'; import {SliderTooltipPortal} from '@app/features/ui/components/slider/SliderTooltipPortal'; import type {SliderProps} from '@app/features/ui/components/slider/SliderTypes'; import {useSliderTooltip} from '@app/features/ui/components/slider/useSliderTooltip'; import KeyboardMode from '@app/features/ui/state/KeyboardMode'; import {Slider as BaseSlider} from '@base-ui/react/slider'; import {msg} from '@lingui/core/macro'; import {useLingui} from '@lingui/react/macro'; import {clsx} from 'clsx'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; export type {SliderProps} from '@app/features/ui/components/slider/SliderTypes'; export const RESET_SLIDER_TO_DEFAULT_VALUE_DESCRIPTOR = msg({ message: 'Reset slider to default value', comment: 'Accessible label for a button that resets a slider to its default value.', }); const SLIDER_VALUE_DESCRIPTOR = msg({ message: 'Slider value', comment: 'Accessible label announcing the current slider value.', }); function getSingleValue(value: number | ReadonlyArray): number { return typeof value === 'number' ? value : (value[0] ?? 0); } function isPointerInteractionReason(reason: string): boolean { return reason === 'track-press' || reason === 'drag'; } export const Slider = React.forwardRef( ( { defaultValue = 10, factoryDefaultValue, minValue = 0, maxValue = 100, disabled = false, equidistant = false, markers, className, ariaLabel, stickToMarkers = false, mini = false, markerPosition = 'above', orientation = 'horizontal', onValueChange, onValueRender, onMarkerRender, asValueChanges, onPointerInteractionChange, stopEventPropagation = false, barStyles = {}, fillStyles = {}, ariaLabelledBy, ariaValueText, children, value: controlledValue, step, showResetButton = false, onReset, resetTooltip, resetButtonPlacement = 'inline', resetLabel, resetAccessory, }, ref, ) => { const {i18n} = useLingui(); const thumbRef = useRef(null); const thumbInputRef = useRef(null); const pointerInteractionRef = useRef(false); const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue); const [showTooltip, setShowTooltip] = useState(false); const [isPointerInteracting, setIsPointerInteracting] = useState(false); const rawValue = controlledValue !== undefined ? controlledValue : uncontrolledValue; const markerState = useMemo( () => buildMarkerState({markers, minValue, maxValue, value: rawValue, equidistant}), [equidistant, markers, maxValue, minValue, rawValue], ); const range = markerState.max - markerState.min; const baseMin = markerState.min; const baseMax = range > 0 ? markerState.max : markerState.min + 1; const resolvedStep = step ?? Math.max((baseMax - baseMin) / 100, Number.EPSILON); const normalizeValue = useCallback( (nextValue: number) => { const boundedValue = clamp(nextValue, markerState.min, markerState.max); return stickToMarkers ? snapValueToMarker(boundedValue, markerState) : boundedValue; }, [markerState, stickToMarkers], ); const value = normalizeValue(rawValue); const usesEquidistantScale = equidistant && markerState.sortedMarkers.length > 1; const visualPercent = usesEquidistantScale ? scaleValueEquidistant(markerState.sortedMarkers, value) : undefined; const resolveBaseValue = useCallback( (newValue: number | ReadonlyArray, reason: string) => { const baseValue = getSingleValue(newValue); if (!usesEquidistantScale || !isPointerInteractionReason(reason)) { return normalizeValue(baseValue); } const basePercent = ((baseValue - baseMin) / (baseMax - baseMin)) * 100; return normalizeValue(unscaleValueEquidistant(markerState.sortedMarkers, basePercent)); }, [baseMax, baseMin, markerState.sortedMarkers, normalizeValue, usesEquidistantScale], ); const indicatorStyle = useMemo(() => { if (visualPercent === undefined) return fillStyles; return orientation === 'vertical' ? {...fillStyles, height: `${visualPercent}%`} : {...fillStyles, width: `${visualPercent}%`}; }, [fillStyles, orientation, visualPercent]); const thumbStyle = useMemo(() => { if (visualPercent === undefined) return undefined; return orientation === 'vertical' ? {bottom: `${visualPercent}%`} : {insetInlineStart: `${visualPercent}%`}; }, [orientation, visualPercent]); const isAtFactoryDefault = factoryDefaultValue !== undefined && value === factoryDefaultValue; const canReset = showResetButton && !isAtFactoryDefault && !disabled; const inlineResetLabel = resetTooltip ?? i18n._(RESET_SLIDER_TO_DEFAULT_VALUE_DESCRIPTOR); const belowResetLabel = resetLabel ?? resetTooltip ?? i18n._(RESET_SLIDER_TO_DEFAULT_VALUE_DESCRIPTOR); const tooltip = useSliderTooltip({ showTooltip, value, isDragging: isPointerInteracting, thumbRef, }); const endPointerInteraction = useCallback(() => { if (!pointerInteractionRef.current) return; pointerInteractionRef.current = false; setIsPointerInteracting(false); setShowTooltip(false); onPointerInteractionChange?.(false); document.dispatchEvent(new CustomEvent('slider-drag-end')); }, [onPointerInteractionChange]); const beginPointerInteraction = useCallback(() => { if (pointerInteractionRef.current) return; pointerInteractionRef.current = true; setIsPointerInteracting(true); setShowTooltip(true); onPointerInteractionChange?.(true); document.dispatchEvent(new CustomEvent('slider-drag-start')); }, [onPointerInteractionChange]); const commitControlledValue = useCallback( (newValue: number) => { if (controlledValue === undefined) { setUncontrolledValue(newValue); } if (asValueChanges) { asValueChanges(newValue); } else { onValueChange?.(newValue); } }, [asValueChanges, controlledValue, onValueChange], ); const handleBaseValueChange = useCallback( (newValue: number | ReadonlyArray, eventDetails: BaseSlider.Root.ChangeEventDetails) => { commitControlledValue(resolveBaseValue(newValue, eventDetails.reason)); }, [commitControlledValue, resolveBaseValue], ); const handleBaseValueCommitted = useCallback( (newValue: number | ReadonlyArray, eventDetails: BaseSlider.Root.CommitEventDetails) => { const nextValue = resolveBaseValue(newValue, eventDetails.reason); if (asValueChanges) { onValueChange?.(nextValue); } endPointerInteraction(); }, [asValueChanges, endPointerInteraction, onValueChange, resolveBaseValue], ); const handleControlPointerDownCapture = useCallback( (event: React.PointerEvent) => { if (disabled || event.button !== 0) return; const target = event.target; if (target instanceof HTMLElement && target.closest('button')) return; beginPointerInteraction(); }, [beginPointerInteraction, disabled], ); const handleThumbPointerEnter = useCallback(() => { setShowTooltip(true); }, []); const handleThumbPointerLeave = useCallback(() => { if (!pointerInteractionRef.current) { setShowTooltip(false); } }, []); const handleThumbFocus = useCallback(() => { if (KeyboardMode.keyboardModeEnabled) { setShowTooltip(true); } }, []); const handleThumbBlur = useCallback(() => { if (!pointerInteractionRef.current) { setShowTooltip(false); } }, []); const handleThumbKeyDown = useCallback(() => { setShowTooltip(true); }, []); const handleReset = useCallback(() => { if (disabled || factoryDefaultValue === undefined) return; const resetValue = normalizeValue(factoryDefaultValue); if (onReset) { onReset(); } else { setUncontrolledValue(resetValue); onValueChange?.(resetValue); } thumbInputRef.current?.focus({preventScroll: true}); }, [disabled, factoryDefaultValue, normalizeValue, onReset, onValueChange]); const handleRootEventPropagation = useCallback( (event: React.SyntheticEvent) => { if (stopEventPropagation) { event.stopPropagation(); } }, [stopEventPropagation], ); useEffect(() => { setUncontrolledValue(defaultValue); }, [defaultValue]); useEffect(() => { if (!isPointerInteracting) return; document.addEventListener('pointerup', endPointerInteraction, true); document.addEventListener('pointercancel', endPointerInteraction, true); window.addEventListener('blur', endPointerInteraction); return () => { document.removeEventListener('pointerup', endPointerInteraction, true); document.removeEventListener('pointercancel', endPointerInteraction, true); window.removeEventListener('blur', endPointerInteraction); }; }, [endPointerInteraction, isPointerInteracting]); useEffect(() => { if (disabled) { endPointerInteraction(); } }, [disabled, endPointerInteraction]); const hasMarks = markerState.markerPositions.length > 0; const sliderLabel = ariaLabel ?? i18n._(SLIDER_VALUE_DESCRIPTOR); const renderedSlider = ( {hasMarks ? (