363 lines
11 KiB
TypeScript
Executable File
363 lines
11 KiB
TypeScript
Executable File
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)];
|
|
}
|