191
common/resources/client/ui/forms/slider/base-slider.tsx
Executable file
191
common/resources/client/ui/forms/slider/base-slider.tsx
Executable file
@@ -0,0 +1,191 @@
|
||||
import React, {ReactNode} from 'react';
|
||||
import clsx from 'clsx';
|
||||
import {getInputFieldClassNames} from '../input-field/get-input-field-class-names';
|
||||
import {UseSliderProps, UseSliderReturn} from './use-slider';
|
||||
|
||||
export interface BaseSliderProps extends UseSliderProps {
|
||||
slider: UseSliderReturn;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function BaseSlider(props: BaseSliderProps) {
|
||||
const {
|
||||
size = 'md',
|
||||
inline,
|
||||
label,
|
||||
showValueLabel = !!label,
|
||||
className,
|
||||
width = 'w-full',
|
||||
slider,
|
||||
children,
|
||||
trackColor = 'primary',
|
||||
fillColor = 'primary',
|
||||
} = props;
|
||||
|
||||
const {
|
||||
domProps,
|
||||
trackRef,
|
||||
getThumbPercent,
|
||||
getThumbValueLabel,
|
||||
labelId,
|
||||
groupId,
|
||||
thumbIds,
|
||||
isDisabled,
|
||||
numberFormatter,
|
||||
minValue,
|
||||
maxValue,
|
||||
step,
|
||||
values,
|
||||
getValueLabel,
|
||||
} = slider;
|
||||
|
||||
let outputValue = '';
|
||||
let maxLabelLength = Math.max(
|
||||
[...numberFormatter.format(minValue)].length,
|
||||
[...numberFormatter.format(maxValue)].length,
|
||||
[...numberFormatter.format(step)].length,
|
||||
);
|
||||
|
||||
if (getValueLabel) {
|
||||
outputValue = getValueLabel(values[0]);
|
||||
} else if (values.length === 1) {
|
||||
outputValue = getThumbValueLabel(0);
|
||||
} else if (values.length === 2) {
|
||||
// This should really use the NumberFormat#formatRange proposal...
|
||||
// https://github.com/tc39/ecma402/issues/393
|
||||
// https://github.com/tc39/proposal-intl-numberformat-v3#formatrange-ecma-402-393
|
||||
outputValue = `${getThumbValueLabel(0)} – ${getThumbValueLabel(1)}`;
|
||||
maxLabelLength =
|
||||
3 +
|
||||
2 *
|
||||
Math.max(
|
||||
maxLabelLength,
|
||||
[...numberFormatter.format(minValue)].length,
|
||||
[...numberFormatter.format(maxValue)].length,
|
||||
);
|
||||
}
|
||||
const style = getInputFieldClassNames({
|
||||
size,
|
||||
disabled: isDisabled,
|
||||
labelDisplay: 'flex',
|
||||
});
|
||||
|
||||
const wrapperClassname = clsx('touch-none', className, width, {
|
||||
'flex items-center': inline,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={wrapperClassname} role="group" id={groupId}>
|
||||
{(label || showValueLabel) && (
|
||||
<div className={clsx(style.label, 'select-none')}>
|
||||
{label && (
|
||||
<label
|
||||
onClick={() => {
|
||||
// Safari does not focus <input type="range"> elements when clicking on an associated <label>,
|
||||
// so do it manually. In addition, make sure we show the focus ring.
|
||||
document.getElementById(thumbIds[0])?.focus();
|
||||
}}
|
||||
id={labelId}
|
||||
htmlFor={groupId}
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
{showValueLabel && (
|
||||
<output
|
||||
htmlFor={thumbIds[0]}
|
||||
className="ml-auto text-right"
|
||||
aria-live="off"
|
||||
style={
|
||||
!maxLabelLength
|
||||
? undefined
|
||||
: {
|
||||
width: `${maxLabelLength}ch`,
|
||||
minWidth: `${maxLabelLength}ch`,
|
||||
}
|
||||
}
|
||||
>
|
||||
{outputValue}
|
||||
</output>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
ref={trackRef}
|
||||
className={clsx('relative', getWrapperHeight(props))}
|
||||
{...domProps}
|
||||
role="presentation"
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
'absolute inset-0 m-auto rounded',
|
||||
getTrackColor(trackColor, isDisabled),
|
||||
getTrackHeight(size),
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={clsx(
|
||||
'absolute inset-0 my-auto rounded',
|
||||
getFillColor(fillColor, isDisabled),
|
||||
getTrackHeight(size),
|
||||
)}
|
||||
style={{width: `${Math.max(getThumbPercent(0) * 100, 0)}%`}}
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getWrapperHeight({size, wrapperHeight}: UseSliderProps): string {
|
||||
if (wrapperHeight) return wrapperHeight;
|
||||
switch (size) {
|
||||
case 'xs':
|
||||
return 'h-14';
|
||||
case 'sm':
|
||||
return 'h-20';
|
||||
default:
|
||||
return 'h-30';
|
||||
}
|
||||
}
|
||||
|
||||
function getTrackHeight(size: UseSliderProps['size']): string {
|
||||
switch (size) {
|
||||
case 'xs':
|
||||
return 'h-2';
|
||||
case 'sm':
|
||||
return 'h-3';
|
||||
default:
|
||||
return 'h-4';
|
||||
}
|
||||
}
|
||||
|
||||
function getTrackColor(color: string, isDisabled: boolean): string {
|
||||
if (isDisabled) {
|
||||
color = 'disabled';
|
||||
}
|
||||
switch (color) {
|
||||
case 'disabled':
|
||||
return 'bg-slider-disabled/60';
|
||||
case 'primary':
|
||||
return 'bg-primary-light';
|
||||
case 'neutral':
|
||||
return 'bg-divider';
|
||||
default:
|
||||
return color;
|
||||
}
|
||||
}
|
||||
|
||||
function getFillColor(color: string, isDisabled: boolean): string {
|
||||
if (isDisabled) {
|
||||
color = 'disabled';
|
||||
}
|
||||
switch (color) {
|
||||
case 'disabled':
|
||||
return 'bg-slider-disabled';
|
||||
case 'primary':
|
||||
return 'bg-primary';
|
||||
default:
|
||||
return color;
|
||||
}
|
||||
}
|
||||
45
common/resources/client/ui/forms/slider/range-slider.tsx
Executable file
45
common/resources/client/ui/forms/slider/range-slider.tsx
Executable file
@@ -0,0 +1,45 @@
|
||||
import {BaseSlider} from './base-slider';
|
||||
import {useSlider, UseSliderProps} from './use-slider';
|
||||
import {SliderThumb} from './slider-thumb';
|
||||
import {useTrans} from '../../../i18n/use-trans';
|
||||
import {message} from '../../../i18n/message';
|
||||
|
||||
interface RangeSliderProps
|
||||
extends UseSliderProps<{start: number; end: number}> {}
|
||||
export function RangeSlider(props: RangeSliderProps) {
|
||||
const {onChange, onChangeEnd, value, defaultValue, ...otherProps} = props;
|
||||
const {trans} = useTrans();
|
||||
|
||||
const baseProps: UseSliderProps = {
|
||||
...otherProps,
|
||||
value: value != null ? [value.start, value.end] : undefined,
|
||||
defaultValue:
|
||||
defaultValue != null
|
||||
? [defaultValue.start, defaultValue.end]
|
||||
: // make sure that useSliderState knows we have two handles
|
||||
[props.minValue ?? 0, props.maxValue ?? 100],
|
||||
onChange(v) {
|
||||
onChange?.({start: v[0], end: v[1]});
|
||||
},
|
||||
onChangeEnd(v) {
|
||||
onChangeEnd?.({start: v[0], end: v[1]});
|
||||
},
|
||||
};
|
||||
|
||||
const slider = useSlider(baseProps);
|
||||
|
||||
return (
|
||||
<BaseSlider {...baseProps} slider={slider}>
|
||||
<SliderThumb
|
||||
ariaLabel={trans(message('minimum'))}
|
||||
index={0}
|
||||
slider={slider}
|
||||
/>
|
||||
<SliderThumb
|
||||
ariaLabel={trans(message('maximum'))}
|
||||
index={1}
|
||||
slider={slider}
|
||||
/>
|
||||
</BaseSlider>
|
||||
);
|
||||
}
|
||||
174
common/resources/client/ui/forms/slider/slider-thumb.tsx
Executable file
174
common/resources/client/ui/forms/slider/slider-thumb.tsx
Executable file
@@ -0,0 +1,174 @@
|
||||
import React, {Ref, useCallback, useEffect, useRef} from 'react';
|
||||
import clsx from 'clsx';
|
||||
import {UseSliderReturn} from './use-slider';
|
||||
import {useGlobalListeners, useObjectRef} from '@react-aria/utils';
|
||||
import {createEventHandler} from '@common/utils/dom/create-event-handler';
|
||||
import {BaseSliderProps} from '@common/ui/forms/slider/base-slider';
|
||||
|
||||
interface SliderThumb {
|
||||
index: number;
|
||||
slider: UseSliderReturn;
|
||||
isDisabled?: boolean;
|
||||
ariaLabel?: string;
|
||||
inputRef?: Ref<HTMLInputElement>;
|
||||
onBlur?: React.FocusEventHandler;
|
||||
fillColor?: BaseSliderProps['fillColor'];
|
||||
}
|
||||
|
||||
export function SliderThumb({
|
||||
index,
|
||||
slider,
|
||||
isDisabled: isThumbDisabled,
|
||||
ariaLabel,
|
||||
inputRef,
|
||||
onBlur,
|
||||
fillColor = 'primary',
|
||||
}: SliderThumb) {
|
||||
const inputObjRef = useObjectRef(inputRef);
|
||||
const {addGlobalListener, removeGlobalListener} = useGlobalListeners();
|
||||
|
||||
const {
|
||||
step,
|
||||
values,
|
||||
focusedThumb,
|
||||
labelId,
|
||||
thumbIds,
|
||||
isDisabled: isSliderDisabled,
|
||||
getThumbPercent,
|
||||
getThumbMinValue,
|
||||
getThumbMaxValue,
|
||||
getThumbValueLabel,
|
||||
setThumbValue,
|
||||
updateDraggedThumbs,
|
||||
isThumbDragging,
|
||||
setThumbEditable,
|
||||
setFocusedThumb,
|
||||
isPointerOver,
|
||||
showThumbOnHoverOnly,
|
||||
thumbSize = 'w-18 h-18',
|
||||
} = slider;
|
||||
|
||||
const isDragging = isThumbDragging(index);
|
||||
const value = values[index];
|
||||
|
||||
// Immediately register editability with the state
|
||||
setThumbEditable(index, !isThumbDisabled);
|
||||
const isDisabled = isThumbDisabled || isSliderDisabled;
|
||||
|
||||
const focusInput = useCallback(() => {
|
||||
if (inputObjRef.current) {
|
||||
inputObjRef.current.focus({preventScroll: true});
|
||||
}
|
||||
}, [inputObjRef]);
|
||||
|
||||
// we will focus the native range input when slider is clicked or thumb is
|
||||
// focused in some other way, and let browser handle keyboard interactions
|
||||
const isFocused = focusedThumb === index;
|
||||
useEffect(() => {
|
||||
if (isFocused) {
|
||||
focusInput();
|
||||
}
|
||||
}, [isFocused, focusInput]);
|
||||
|
||||
const currentPointer = useRef<number | undefined>(undefined);
|
||||
const handlePointerUp = (e: PointerEvent) => {
|
||||
if (e.pointerId === currentPointer.current) {
|
||||
focusInput();
|
||||
updateDraggedThumbs(index, false);
|
||||
removeGlobalListener(window, 'pointerup', handlePointerUp, false);
|
||||
}
|
||||
};
|
||||
|
||||
const className = clsx(
|
||||
'outline-none rounded-full top-1/2 -translate-y-1/2 -translate-x-1/2 absolute inset-0 transition-button duration-200',
|
||||
thumbSize,
|
||||
!isDisabled && 'shadow-md',
|
||||
thumbColor({fillColor, isDisabled, isDragging: isDragging}),
|
||||
// show thumb on hover and while dragging, otherwise "blur" event will fire on thumb and dragging will stop
|
||||
!showThumbOnHoverOnly ||
|
||||
(showThumbOnHoverOnly && isDragging) ||
|
||||
isPointerOver
|
||||
? 'visible'
|
||||
: 'invisible',
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="presentation"
|
||||
className={className}
|
||||
style={{
|
||||
left: `${Math.max(getThumbPercent(index) * 100, 0)}%`,
|
||||
}}
|
||||
onPointerDown={e => {
|
||||
if (e.button !== 0 || e.altKey || e.ctrlKey || e.metaKey) {
|
||||
return;
|
||||
}
|
||||
focusInput();
|
||||
currentPointer.current = e.pointerId;
|
||||
updateDraggedThumbs(index, true);
|
||||
|
||||
addGlobalListener(window, 'pointerup', handlePointerUp, false);
|
||||
}}
|
||||
>
|
||||
<input
|
||||
id={thumbIds[index]}
|
||||
onKeyDown={createEventHandler(() => {
|
||||
updateDraggedThumbs(index, true);
|
||||
})}
|
||||
onKeyUp={createEventHandler(() => {
|
||||
// make sure "onChangeEnd" is fired on keyboard navigation
|
||||
updateDraggedThumbs(index, false);
|
||||
})}
|
||||
ref={inputObjRef}
|
||||
tabIndex={!isDisabled ? 0 : undefined}
|
||||
min={getThumbMinValue(index)}
|
||||
max={getThumbMaxValue(index)}
|
||||
step={step}
|
||||
value={value}
|
||||
disabled={isDisabled}
|
||||
aria-label={ariaLabel}
|
||||
aria-labelledby={labelId}
|
||||
aria-orientation="horizontal"
|
||||
aria-valuetext={getThumbValueLabel(index)}
|
||||
onFocus={() => {
|
||||
setFocusedThumb(index);
|
||||
}}
|
||||
onBlur={e => {
|
||||
setFocusedThumb(undefined);
|
||||
updateDraggedThumbs(index, false);
|
||||
onBlur?.(e);
|
||||
}}
|
||||
onChange={e => {
|
||||
setThumbValue(index, parseFloat(e.target.value));
|
||||
}}
|
||||
type="range"
|
||||
className="sr-only"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SliderThumbColorProps {
|
||||
isDisabled?: boolean;
|
||||
isDragging: boolean;
|
||||
fillColor?: BaseSliderProps['fillColor'];
|
||||
}
|
||||
|
||||
function thumbColor({
|
||||
isDisabled,
|
||||
isDragging,
|
||||
fillColor,
|
||||
}: SliderThumbColorProps): string {
|
||||
if (isDisabled) {
|
||||
return 'bg-slider-disabled cursor-default';
|
||||
}
|
||||
|
||||
if (fillColor && fillColor !== 'primary') {
|
||||
return fillColor;
|
||||
}
|
||||
|
||||
return clsx(
|
||||
'hover:bg-primary-dark',
|
||||
isDragging ? 'bg-primary-dark' : 'bg-primary',
|
||||
);
|
||||
}
|
||||
61
common/resources/client/ui/forms/slider/slider.tsx
Executable file
61
common/resources/client/ui/forms/slider/slider.tsx
Executable file
@@ -0,0 +1,61 @@
|
||||
import {BaseSlider} from './base-slider';
|
||||
import {useSlider, UseSliderProps} from './use-slider';
|
||||
import React, {Ref} from 'react';
|
||||
import {SliderThumb} from './slider-thumb';
|
||||
import {useController} from 'react-hook-form';
|
||||
import {mergeProps} from '@react-aria/utils';
|
||||
|
||||
interface SliderProps extends UseSliderProps<number> {
|
||||
inputRef?: Ref<HTMLInputElement>;
|
||||
onBlur?: React.FocusEventHandler;
|
||||
}
|
||||
export function Slider({inputRef, onBlur, ...props}: SliderProps) {
|
||||
const {onChange, onChangeEnd, value, defaultValue, ...otherProps} = props;
|
||||
|
||||
const baseProps: UseSliderProps = {
|
||||
...otherProps,
|
||||
// Normalize `value: number[]` to `value: number`
|
||||
value: value != null ? [value] : undefined,
|
||||
defaultValue: defaultValue != null ? [defaultValue] : undefined,
|
||||
onChange: (v: number[]): void => {
|
||||
onChange?.(v[0]);
|
||||
},
|
||||
onChangeEnd: (v: number[]): void => {
|
||||
onChangeEnd?.(v[0]);
|
||||
},
|
||||
};
|
||||
|
||||
const slider = useSlider(baseProps);
|
||||
|
||||
return (
|
||||
<BaseSlider {...baseProps} slider={slider}>
|
||||
<SliderThumb
|
||||
fillColor={props.fillColor}
|
||||
index={0}
|
||||
slider={slider}
|
||||
inputRef={inputRef}
|
||||
onBlur={onBlur}
|
||||
/>
|
||||
</BaseSlider>
|
||||
);
|
||||
}
|
||||
|
||||
export interface FormSliderProps extends SliderProps {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export function FormSlider({name, ...props}: FormSliderProps) {
|
||||
const {
|
||||
field: {onChange, onBlur, value = '', ref},
|
||||
} = useController({
|
||||
name,
|
||||
});
|
||||
|
||||
const formProps: SliderProps = {
|
||||
onChange,
|
||||
onBlur,
|
||||
value: value || '', // avoid issues with "null" value when setting form defaults from backend model
|
||||
};
|
||||
|
||||
return <Slider inputRef={ref} {...mergeProps(formProps, props)} />;
|
||||
}
|
||||
362
common/resources/client/ui/forms/slider/use-slider.ts
Executable file
362
common/resources/client/ui/forms/slider/use-slider.ts
Executable file
@@ -0,0 +1,362 @@
|
||||
import {
|
||||
mergeProps,
|
||||
snapValueToStep,
|
||||
useGlobalListeners,
|
||||
} from '@react-aria/utils';
|
||||
import {useControlledState} from '@react-stately/utils';
|
||||
import React, {
|
||||
HTMLAttributes,
|
||||
ReactNode,
|
||||
RefObject,
|
||||
useId,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import {clamp} from '@common/utils/number/clamp';
|
||||
import {usePointerEvents} from '../../interactions/use-pointer-events';
|
||||
import {useNumberFormatter} from '@common/i18n/use-number-formatter';
|
||||
import type {NumberFormatOptions} from '@internationalized/number';
|
||||
|
||||
export interface UseSliderProps<T = number[]> {
|
||||
formatOptions?: NumberFormatOptions;
|
||||
onPointerDown?: () => void;
|
||||
onPointerMove?: (e: React.PointerEvent) => void;
|
||||
onChange?: (value: T) => void;
|
||||
onChangeEnd?: (value: T) => void;
|
||||
value?: T;
|
||||
defaultValue?: T;
|
||||
getValueLabel?: (value: number) => string;
|
||||
minValue?: number;
|
||||
maxValue?: number;
|
||||
step?: number;
|
||||
isDisabled?: boolean;
|
||||
size?: 'xs' | 'sm' | 'md';
|
||||
label?: ReactNode;
|
||||
inline?: boolean;
|
||||
className?: string;
|
||||
width?: string;
|
||||
showValueLabel?: boolean;
|
||||
fillColor?: 'primary' | string;
|
||||
trackColor?: 'primary' | 'neutral' | string;
|
||||
showThumbOnHoverOnly?: boolean;
|
||||
thumbSize?: string;
|
||||
wrapperHeight?: string;
|
||||
}
|
||||
|
||||
export interface UseSliderReturn {
|
||||
domProps: HTMLAttributes<HTMLElement>;
|
||||
trackRef: RefObject<HTMLDivElement>;
|
||||
isPointerOver: boolean;
|
||||
showThumbOnHoverOnly?: boolean;
|
||||
thumbSize?: string;
|
||||
step: number;
|
||||
isDisabled: boolean;
|
||||
values: number[];
|
||||
minValue: number;
|
||||
maxValue: number;
|
||||
focusedThumb: number | undefined;
|
||||
labelId: string | undefined;
|
||||
groupId: string;
|
||||
thumbIds: string[];
|
||||
numberFormatter: Intl.NumberFormat;
|
||||
getThumbPercent: (index: number) => number;
|
||||
getThumbMinValue: (index: number) => number;
|
||||
getThumbMaxValue: (index: number) => number;
|
||||
getThumbValueLabel: (index: number) => string;
|
||||
setThumbValue: (index: number, value: number) => void;
|
||||
updateDraggedThumbs: (index: number, dragging: boolean) => void;
|
||||
isThumbDragging: (index: number) => boolean;
|
||||
setThumbEditable: (index: number, editable: boolean) => void;
|
||||
setFocusedThumb: (index: number | undefined) => void;
|
||||
getValueLabel?: (value: number) => string;
|
||||
}
|
||||
|
||||
export function useSlider({
|
||||
minValue = 0,
|
||||
maxValue = 100,
|
||||
isDisabled = false,
|
||||
step = 1,
|
||||
formatOptions,
|
||||
onChangeEnd,
|
||||
onPointerDown,
|
||||
label,
|
||||
getValueLabel,
|
||||
showThumbOnHoverOnly,
|
||||
thumbSize,
|
||||
onPointerMove,
|
||||
...props
|
||||
}: UseSliderProps): UseSliderReturn {
|
||||
const [isPointerOver, setIsPointerOver] = useState(false);
|
||||
const numberFormatter = useNumberFormatter(formatOptions);
|
||||
const {addGlobalListener, removeGlobalListener} = useGlobalListeners();
|
||||
const trackRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// values will be stored in internal state as an array for both slider and range slider
|
||||
const [values, setValues] = useControlledState<number[]>(
|
||||
props.value ? props.value : undefined,
|
||||
props.defaultValue ?? ([minValue] as any),
|
||||
props.onChange as any,
|
||||
);
|
||||
// need to also store values in ref, because state value would
|
||||
// lag behind by one between pointer down and move callbacks
|
||||
const valuesRef = useRef<number[] | null>(null);
|
||||
valuesRef.current = values;
|
||||
|
||||
// indices of thumbs that are being dragged currently (state and ref for same reasons as above)
|
||||
const [draggedThumbs, setDraggedThumbs] = useState<boolean[]>(
|
||||
new Array(values.length).fill(false),
|
||||
);
|
||||
const draggedThumbsRef = useRef<boolean[] | null>(null);
|
||||
draggedThumbsRef.current = draggedThumbs;
|
||||
|
||||
// formatted value for <output> and thumb aria labels
|
||||
function getFormattedValue(value: number) {
|
||||
return numberFormatter.format(value);
|
||||
}
|
||||
|
||||
const isThumbDragging = (index: number) => {
|
||||
return draggedThumbsRef.current?.[index] || false;
|
||||
};
|
||||
|
||||
const getThumbValueLabel = (index: number) =>
|
||||
getFormattedValue(values[index]);
|
||||
|
||||
const getThumbMinValue = (index: number) =>
|
||||
index === 0 ? minValue : values[index - 1];
|
||||
const getThumbMaxValue = (index: number) =>
|
||||
index === values.length - 1 ? maxValue : values[index + 1];
|
||||
|
||||
const setThumbValue = (index: number, value: number) => {
|
||||
if (isDisabled || !isThumbEditable(index) || !valuesRef.current) {
|
||||
return;
|
||||
}
|
||||
const thisMin = getThumbMinValue(index);
|
||||
const thisMax = getThumbMaxValue(index);
|
||||
|
||||
// Round value to multiple of step, clamp value between min and max
|
||||
value = snapValueToStep(value, thisMin, thisMax, step);
|
||||
valuesRef.current = replaceIndex(valuesRef.current, index, value);
|
||||
setValues(valuesRef.current);
|
||||
};
|
||||
|
||||
// update "dragging" status of specified thumb
|
||||
const updateDraggedThumbs = (index: number, dragging: boolean) => {
|
||||
if (isDisabled || !isThumbEditable(index)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const wasDragging = draggedThumbsRef.current?.[index];
|
||||
draggedThumbsRef.current = replaceIndex(
|
||||
draggedThumbsRef.current || [],
|
||||
index,
|
||||
dragging,
|
||||
);
|
||||
setDraggedThumbs(draggedThumbsRef.current);
|
||||
|
||||
// Call onChangeEnd if no handles are dragging.
|
||||
if (onChangeEnd && wasDragging && !draggedThumbsRef.current.some(Boolean)) {
|
||||
onChangeEnd(valuesRef.current || []);
|
||||
}
|
||||
};
|
||||
|
||||
const [focusedThumb, setFocusedThumb] = useState<number | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
const getValuePercent = (value: number) => {
|
||||
const x = Math.min(1, (value - minValue) / (maxValue - minValue));
|
||||
if (isNaN(x)) {
|
||||
return 0;
|
||||
}
|
||||
return x;
|
||||
};
|
||||
|
||||
const getThumbPercent = (index: number) =>
|
||||
getValuePercent(valuesRef.current![index]);
|
||||
|
||||
const setThumbPercent = (index: number, percent: number) => {
|
||||
setThumbValue(index, getPercentValue(percent));
|
||||
};
|
||||
|
||||
const getRoundedValue = (value: number) =>
|
||||
Math.round((value - minValue) / step) * step + minValue;
|
||||
|
||||
const getPercentValue = (percent: number) => {
|
||||
const val = percent * (maxValue - minValue) + minValue;
|
||||
return clamp(getRoundedValue(val), minValue, maxValue);
|
||||
};
|
||||
|
||||
// allows disabling individual thumbs in range slider, instead of disable the whole slider
|
||||
const editableThumbsRef = useRef<boolean[]>(
|
||||
new Array(values.length).fill(true),
|
||||
);
|
||||
const isThumbEditable = (index: number) => editableThumbsRef.current[index];
|
||||
const setThumbEditable = (index: number, editable: boolean) => {
|
||||
editableThumbsRef.current[index] = editable;
|
||||
};
|
||||
|
||||
// When the user clicks or drags the track, we want the motion to set and drag the
|
||||
// closest thumb. Hence, we also need to install useMove() on the track element.
|
||||
// Here, we keep track of which index is the "closest" to the drag start point.
|
||||
// It is set onMouseDown/onTouchDown; see trackProps below.
|
||||
const realTimeTrackDraggingIndex = useRef<number | null>(null);
|
||||
|
||||
const currentPointer = useRef<number | null | undefined>(undefined);
|
||||
const handlePointerDown = (e: React.PointerEvent) => {
|
||||
if (
|
||||
e.pointerType === 'mouse' &&
|
||||
(e.button !== 0 || e.altKey || e.ctrlKey || e.metaKey)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
onPointerDown?.();
|
||||
|
||||
// We only trigger track-dragging if the user clicks on the track itself and nothing is currently being dragged.
|
||||
if (
|
||||
trackRef.current &&
|
||||
!isDisabled &&
|
||||
values.every((_, i) => !draggedThumbs[i])
|
||||
) {
|
||||
const size = trackRef.current.offsetWidth;
|
||||
// Find the closest thumb
|
||||
const trackPosition = trackRef.current.getBoundingClientRect().left;
|
||||
const offset = e.clientX - trackPosition;
|
||||
const percent = offset / size;
|
||||
const value = getPercentValue(percent);
|
||||
|
||||
// to find the closet thumb we split the array based on the first thumb position to the "right/end" of the click.
|
||||
let closestThumb;
|
||||
const split = values.findIndex(v => value - v < 0);
|
||||
if (split === 0) {
|
||||
// If the index is zero then the closest thumb is the first one
|
||||
closestThumb = split;
|
||||
} else if (split === -1) {
|
||||
// If no index is found they've clicked past all the thumbs
|
||||
closestThumb = values.length - 1;
|
||||
} else {
|
||||
const lastLeft = values[split - 1];
|
||||
const firstRight = values[split];
|
||||
// Pick the last left/start thumb, unless they are stacked on top of each other, then pick the right/end one
|
||||
if (Math.abs(lastLeft - value) < Math.abs(firstRight - value)) {
|
||||
closestThumb = split - 1;
|
||||
} else {
|
||||
closestThumb = split;
|
||||
}
|
||||
}
|
||||
|
||||
// Confirm that the found closest thumb is editable, not disabled, and move it
|
||||
if (closestThumb >= 0 && isThumbEditable(closestThumb)) {
|
||||
// Don't un-focus anything
|
||||
e.preventDefault();
|
||||
|
||||
realTimeTrackDraggingIndex.current = closestThumb;
|
||||
setFocusedThumb(closestThumb);
|
||||
currentPointer.current = e.pointerId;
|
||||
|
||||
updateDraggedThumbs(realTimeTrackDraggingIndex.current, true);
|
||||
setThumbValue(closestThumb, value);
|
||||
|
||||
addGlobalListener(window, 'pointerup', onUpTrack, false);
|
||||
} else {
|
||||
realTimeTrackDraggingIndex.current = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const currentPosition = useRef<number | null>(null);
|
||||
const {domProps: moveDomProps} = usePointerEvents({
|
||||
onPointerDown: handlePointerDown,
|
||||
onMoveStart() {
|
||||
currentPosition.current = null;
|
||||
},
|
||||
onMove(e, deltaX) {
|
||||
const size = trackRef.current?.offsetWidth || 0;
|
||||
|
||||
if (currentPosition.current == null) {
|
||||
currentPosition.current =
|
||||
getThumbPercent(realTimeTrackDraggingIndex.current || 0) * size;
|
||||
}
|
||||
|
||||
currentPosition.current += deltaX;
|
||||
|
||||
if (realTimeTrackDraggingIndex.current != null && trackRef.current) {
|
||||
const percent = clamp(currentPosition.current / size, 0, 1);
|
||||
setThumbPercent(realTimeTrackDraggingIndex.current, percent);
|
||||
}
|
||||
},
|
||||
onMoveEnd() {
|
||||
if (realTimeTrackDraggingIndex.current != null) {
|
||||
updateDraggedThumbs(realTimeTrackDraggingIndex.current, false);
|
||||
realTimeTrackDraggingIndex.current = null;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const domProps = mergeProps(moveDomProps, {
|
||||
onPointerEnter: () => {
|
||||
setIsPointerOver(true);
|
||||
},
|
||||
onPointerLeave: () => {
|
||||
setIsPointerOver(false);
|
||||
},
|
||||
onPointerMove: (e: React.PointerEvent) => {
|
||||
onPointerMove?.(e);
|
||||
},
|
||||
});
|
||||
|
||||
const onUpTrack = (e: PointerEvent) => {
|
||||
const id = e.pointerId;
|
||||
if (id === currentPointer.current) {
|
||||
if (realTimeTrackDraggingIndex.current != null) {
|
||||
updateDraggedThumbs(realTimeTrackDraggingIndex.current, false);
|
||||
realTimeTrackDraggingIndex.current = null;
|
||||
}
|
||||
|
||||
removeGlobalListener(window, 'pointerup', onUpTrack, false);
|
||||
}
|
||||
};
|
||||
|
||||
const id = useId();
|
||||
const labelId = label ? `${id}-label` : undefined;
|
||||
const groupId = `${id}-group`;
|
||||
const thumbIds = [...Array(values.length)].map((v, i) => {
|
||||
return `${id}-thumb-${i}`;
|
||||
});
|
||||
|
||||
return {
|
||||
domProps,
|
||||
trackRef,
|
||||
isDisabled,
|
||||
step,
|
||||
values,
|
||||
minValue,
|
||||
maxValue,
|
||||
focusedThumb,
|
||||
labelId,
|
||||
groupId,
|
||||
thumbIds,
|
||||
numberFormatter,
|
||||
getThumbPercent,
|
||||
getThumbMinValue,
|
||||
getThumbMaxValue,
|
||||
getThumbValueLabel,
|
||||
isThumbDragging,
|
||||
setThumbValue,
|
||||
updateDraggedThumbs,
|
||||
setThumbEditable,
|
||||
setFocusedThumb,
|
||||
getValueLabel,
|
||||
isPointerOver,
|
||||
showThumbOnHoverOnly,
|
||||
thumbSize,
|
||||
};
|
||||
}
|
||||
|
||||
function replaceIndex<T>(array: T[], index: number, value: T) {
|
||||
if (array[index] === value) {
|
||||
return array;
|
||||
}
|
||||
|
||||
return [...array.slice(0, index), value, ...array.slice(index + 1)];
|
||||
}
|
||||
Reference in New Issue
Block a user