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,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;
}
}

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

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

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

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