first commit
Some checks failed
Build / run (push) Has been cancelled

This commit is contained in:
maher
2025-10-29 11:42:25 +01:00
commit 703f50a09d
4595 changed files with 385164 additions and 0 deletions

View 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)];
}