Skip to content

Instantly share code, notes, and snippets.

@brandonscript
Last active March 16, 2025 00:06
Show Gist options
  • Save brandonscript/f8d1aa0ec648a5da7fc684fa860f27b0 to your computer and use it in GitHub Desktop.
Save brandonscript/f8d1aa0ec648a5da7fc684fa860f27b0 to your computer and use it in GitHub Desktop.
A custom Material UI Slider component with a `getThumbIndex` function exposed as a prop
"use client";
// This file is copied from MUI's Slider component and modified to support multiple thumbs with the getThumbIndex API
// exposed as a prop so that we can programatically determine which thumb is focused outside of this component.
// Original source: https://github.com/mui/material-ui/blob/v5.x/packages/mui-material/src/Slider/Slider.js
// Docs: https://v5.mui.com/material-ui/react-slider/
import _extends from "@babel/runtime/helpers/esm/extends";
import {
SliderMark,
SliderMarkLabel,
type SliderOwnerState,
type SliderProps,
SliderRail,
SliderRoot,
SliderThumb,
SliderTrack,
SliderValueLabel,
} from "@mui/material";
import { useDefaultProps } from "@mui/material/DefaultPropsProvider";
import { getSliderUtilityClass } from "@mui/material/Slider/sliderClasses";
import { Identity, valueToPercent } from "@mui/material/Slider/useSlider";
import type { Mark, UseSliderParameters } from "@mui/material/Slider/useSlider.types";
import areArraysEqual from "@mui/material/utils/areArraysEqual";
import capitalize from "@mui/material/utils/capitalize";
import shouldSpreadAdditionalProps from "@mui/material/utils/shouldSpreadAdditionalProps";
import { useRtl } from "@mui/system/RtlProvider";
import {
clamp,
unstable_ownerDocument as ownerDocument,
unstable_useControlled as useControlled,
unstable_useEnhancedEffect as useEnhancedEffect,
unstable_useEventCallback as useEventCallback,
unstable_useForkRef as useForkRef,
unstable_useIsFocusVisible as useIsFocusVisible,
visuallyHidden,
} from "@mui/utils";
import composeClasses from "@mui/utils/composeClasses";
import extractEventHandlers from "@mui/utils/extractEventHandlers";
import isHostComponent from "@mui/utils/isHostComponent";
import useSlotProps from "@mui/utils/useSlotProps";
import clsx from "clsx";
import * as React from "react";
import type { OkAny } from "@/app/types/general.types";
const INTENTIONAL_DRAG_COUNT_THRESHOLD = 2;
function asc(a, b) {
return a - b;
}
function findClosest(values, currentValue) {
var _values$reduce;
const { index: closestIndex } =
(_values$reduce = values.reduce((acc, value, index) => {
const distance = Math.abs(currentValue - value);
if (acc === null || distance < acc.distance || distance === acc.distance) {
return {
distance,
index,
};
}
return acc;
}, null)) != null
? _values$reduce
: {};
return closestIndex;
}
function trackFinger(event, touchId) {
// The event is TouchEvent
if (touchId.current !== undefined && event.changedTouches) {
const touchEvent = event;
for (let i = 0; i < touchEvent.changedTouches.length; i += 1) {
const touch = touchEvent.changedTouches[i];
if (touch.identifier === touchId.current) {
return {
x: touch.clientX,
y: touch.clientY,
};
}
}
return false;
}
// The event is MouseEvent
return {
x: event.clientX,
y: event.clientY,
};
}
function percentToValue(percent, min, max) {
return (max - min) * percent + min;
}
function getDecimalPrecision(num) {
// This handles the case when num is very small (0.00000001), js will turn this into 1e-8.
// When num is bigger than 1 or less than -1 it won't get converted to this notation so it's fine.
if (Math.abs(num) < 1) {
const parts = num.toExponential().split("e-");
const matissaDecimalPart = parts[0].split(".")[1];
return (matissaDecimalPart ? matissaDecimalPart.length : 0) + parseInt(parts[1], 10);
}
const decimalPart = num.toString().split(".")[1];
return decimalPart ? decimalPart.length : 0;
}
function roundValueToStep(value, step, min) {
const nearest = Math.round((value - min) / step) * step + min;
return Number(nearest.toFixed(getDecimalPrecision(step)));
}
function setValueIndex({ values, newValue, index }) {
const output = values.slice();
output[index] = newValue;
return output.sort(asc);
}
function focusThumb({
sliderRef,
activeIndex,
setActive,
}: {
sliderRef: React.MutableRefObject<HTMLElement | null>;
activeIndex: number;
setActive?: (index: number) => void;
}) {
var _sliderRef$current, _doc$activeElement;
const doc = ownerDocument(sliderRef.current);
if (
!((_sliderRef$current = sliderRef.current) != null && _sliderRef$current.contains(doc.activeElement)) ||
Number(
doc == null || (_doc$activeElement = doc.activeElement) == null
? void 0
: _doc$activeElement.getAttribute("data-index"),
) !== activeIndex
) {
var _sliderRef$current2;
(_sliderRef$current2 = sliderRef.current) == null ||
_sliderRef$current2.querySelector(`[type="range"][data-index="${activeIndex}"]`).focus();
}
if (setActive) {
setActive(activeIndex);
}
}
function areValuesEqual(newValue, oldValue) {
if (typeof newValue === "number" && typeof oldValue === "number") {
return newValue === oldValue;
}
if (typeof newValue === "object" && typeof oldValue === "object") {
return areArraysEqual(newValue, oldValue);
}
return false;
}
const axisProps = {
horizontal: {
offset: (percent) => ({
left: `${percent}%`,
}),
leap: (percent) => ({
width: `${percent}%`,
}),
},
"horizontal-reverse": {
offset: (percent) => ({
right: `${percent}%`,
}),
leap: (percent) => ({
width: `${percent}%`,
}),
},
vertical: {
offset: (percent) => ({
bottom: `${percent}%`,
}),
leap: (percent) => ({
height: `${percent}%`,
}),
},
};
// TODO: remove support for Safari < 13.
// https://caniuse.com/#search=touch-action
//
// Safari, on iOS, supports touch action since v13.
// Over 80% of the iOS phones are compatible
// in August 2020.
// Utilizing the CSS.supports method to check if touch-action is supported.
// Since CSS.supports is supported on all but Edge@12 and IE and touch-action
// is supported on both Edge@12 and IE if CSS.supports is not available that means that
// touch-action will be supported
let cachedSupportsTouchActionNone;
function doesSupportTouchActionNone() {
if (cachedSupportsTouchActionNone === undefined) {
if (typeof CSS !== "undefined" && typeof CSS.supports === "function") {
cachedSupportsTouchActionNone = CSS.supports("touch-action", "none");
} else {
cachedSupportsTouchActionNone = true;
}
}
return cachedSupportsTouchActionNone;
}
/**
*
* Demos:
*
* - [Slider](https://mui.com/base-ui/react-slider/#hook)
*
* API:
*
* - [useSlider API](https://mui.com/base-ui/react-slider/hooks-api/#use-slider)
*/
const asWithOwnerState = (obj: OkAny) => obj as OkAny & { ownerState?: SliderOwnerState };
type CustomUseSliderParameters = UseSliderParameters & {
getThumbIndex?: (
values: number[],
currentValue: number,
min: number, // <- new, not in `findClosest` fn
max: number // <- also new
) => number;
};
export function useCustomMuiSlider(parameters: CustomUseSliderParameters) {
const {
"aria-labelledby": ariaLabelledby,
defaultValue,
disabled = false,
disableSwap = false,
isRtl = false,
getThumbIndex = findClosest,
marks: marksProp = false,
max = 100,
min = 0,
name,
onChange,
onChangeCommitted,
orientation = "horizontal",
rootRef: ref,
scale = Identity,
step = 1,
shiftStep = 10,
tabIndex,
value: valueProp,
} = parameters;
const touchId = React.useRef<number | undefined>(undefined);
// We can't use the :active browser pseudo-classes.
// - The active state isn't triggered when clicking on the rail.
// - The active state isn't transferred when inversing a range slider.
const [active, setActive] = React.useState(-1);
const [open, setOpen] = React.useState(-1);
const [dragging, setDragging] = React.useState(false);
const moveCount = React.useRef(0);
const [valueDerived, setValueState] = useControlled({
controlled: valueProp,
default: defaultValue != null ? defaultValue : min,
name: "Slider",
});
const handleChange =
onChange &&
((event, value, thumbIndex) => {
// Redefine target to allow name and value to be read.
// This allows seamless integration with the most popular form libraries.
// https://github.com/mui/material-ui/issues/13485#issuecomment-676048492
// Clone the event to not override `target` of the original event.
const nativeEvent = event.nativeEvent || event;
console.log("focusedThumbIndex", thumbIndex);
// @ts-ignore The nativeEvent is function, not object
const clonedEvent = new nativeEvent.constructor(nativeEvent.type, nativeEvent);
Object.defineProperty(clonedEvent, "target", {
writable: true,
value: {
value,
name,
},
});
onChange(clonedEvent, value, thumbIndex);
});
const range = Array.isArray(valueDerived);
let values = range ? valueDerived.slice().sort(asc) : [valueDerived];
values = values.map((value) => (value == null ? min : clamp(value, min, max)));
const marks =
marksProp === true && step !== null
? [...Array(Math.floor((max - min) / step) + 1)].map((_, index) => ({
value: min + step * index,
}))
: marksProp || [];
const marksValues = (marks as OkAny[]).map((mark) => mark.value);
const {
isFocusVisibleRef,
onBlur: handleBlurVisible,
onFocus: handleFocusVisible,
ref: focusVisibleRef,
} = useIsFocusVisible();
const [focusedThumbIndex, setFocusedThumbIndex] = React.useState(-1);
const sliderRef = React.useRef<HTMLElement>(null);
const handleFocusRef = useForkRef(focusVisibleRef, sliderRef);
const handleRef = useForkRef(ref, handleFocusRef);
const createHandleHiddenInputFocus = (otherHandlers) => (event) => {
var _otherHandlers$onFocu;
const index = Number(event.currentTarget.getAttribute("data-index"));
handleFocusVisible(event);
if (isFocusVisibleRef.current === true) {
setFocusedThumbIndex(index);
}
setOpen(index);
otherHandlers == null ||
(_otherHandlers$onFocu = otherHandlers.onFocus) == null ||
_otherHandlers$onFocu.call(otherHandlers, event);
};
const createHandleHiddenInputBlur = (otherHandlers) => (event) => {
var _otherHandlers$onBlur;
handleBlurVisible(event);
if (isFocusVisibleRef.current === false) {
setFocusedThumbIndex(-1);
}
setOpen(-1);
otherHandlers == null ||
(_otherHandlers$onBlur = otherHandlers.onBlur) == null ||
_otherHandlers$onBlur.call(otherHandlers, event);
};
const changeValue = (event, valueInput) => {
const index = Number(event.currentTarget.getAttribute("data-index"));
console.log("changing value for thumb index", index);
const value = values[index];
const marksIndex = marksValues.indexOf(value);
let newValue = valueInput;
if (marks && step == null) {
const maxMarksValue = marksValues[marksValues.length - 1];
if (newValue > maxMarksValue) {
newValue = maxMarksValue;
} else if (newValue < marksValues[0]) {
newValue = marksValues[0];
} else {
newValue = newValue < value ? marksValues[marksIndex - 1] : marksValues[marksIndex + 1];
}
}
newValue = clamp(newValue, min, max);
if (range) {
// Bound the new value to the thumb's neighbours.
if (disableSwap) {
newValue = clamp(newValue, values[index - 1] || -Infinity, values[index + 1] || Infinity);
}
const previousValue = newValue;
newValue = setValueIndex({
values,
newValue,
index,
});
let activeIndex = index;
// Potentially swap the index if needed.
if (!disableSwap) {
activeIndex = newValue.indexOf(previousValue);
}
focusThumb({
sliderRef,
activeIndex,
});
}
setValueState(newValue);
console.log("setting focused thumb index to", index);
setFocusedThumbIndex(index);
if (handleChange && !areValuesEqual(newValue, valueDerived)) {
handleChange(event, newValue, index);
}
if (onChangeCommitted) {
onChangeCommitted(event, newValue);
}
};
const createHandleHiddenInputKeyDown = (otherHandlers) => (event) => {
var _otherHandlers$onKeyD;
// The Shift + Up/Down keyboard shortcuts for moving the slider makes sense to be supported
// only if the step is defined. If the step is null, this means tha the marks are used for specifying the valid values.
if (step !== null) {
const index = Number(event.currentTarget.getAttribute("data-index"));
const value = values[index];
let newValue: number | null = null;
if (((event.key === "ArrowLeft" || event.key === "ArrowDown") && event.shiftKey) || event.key === "PageDown") {
newValue = Math.max(value - shiftStep, min);
} else if (
((event.key === "ArrowRight" || event.key === "ArrowUp") && event.shiftKey) ||
event.key === "PageUp"
) {
newValue = Math.min(value + shiftStep, max);
}
if (newValue !== null) {
changeValue(event, newValue);
event.preventDefault();
}
}
otherHandlers == null ||
(_otherHandlers$onKeyD = otherHandlers.onKeyDown) == null ||
_otherHandlers$onKeyD.call(otherHandlers, event);
};
useEnhancedEffect(() => {
if (disabled && sliderRef?.current?.contains(document.activeElement)) {
var _document$activeEleme;
// This is necessary because Firefox and Safari will keep focus
// on a disabled element:
// https://codesandbox.io/p/sandbox/mui-pr-22247-forked-h151h?file=/src/App.js
// @ts-ignore
(_document$activeEleme = document.activeElement) == null || _document$activeEleme.blur();
}
}, [disabled]);
if (disabled && active !== -1) {
setActive(-1);
}
if (disabled && focusedThumbIndex !== -1) {
setFocusedThumbIndex(-1);
}
const createHandleHiddenInputChange = (otherHandlers) => (event) => {
var _otherHandlers$onChan;
(_otherHandlers$onChan = otherHandlers.onChange) == null || _otherHandlers$onChan.call(otherHandlers, event);
// @ts-ignore
changeValue(event, event.target.valueAsNumber);
};
const previousIndex = React.useRef<number | undefined>(undefined);
let axis = orientation;
if (isRtl && orientation === "horizontal") {
axis += "-reverse";
}
const getFingerNewValue = ({ finger, move = false, getThumbIndex }) => {
const { current: slider } = sliderRef;
const { width, height, bottom, left } = (slider as OkAny).getBoundingClientRect();
let percent;
if (axis.indexOf("vertical") === 0) {
percent = (bottom - finger.y) / height;
} else {
percent = (finger.x - left) / width;
}
if (axis.indexOf("-reverse") !== -1) {
percent = 1 - percent;
}
let newValue;
newValue = percentToValue(percent, min, max);
if (step) {
newValue = roundValueToStep(newValue, step, min);
} else {
const closestIndex = getThumbIndex(marksValues, newValue, min, max);
newValue = marksValues[closestIndex];
}
newValue = clamp(newValue, min, max);
let activeIndex = 0;
if (range) {
if (!move) {
activeIndex = getThumbIndex(values, newValue, min, max);
} else {
activeIndex = Number(previousIndex.current);
}
// Bound the new value to the thumb's neighbours.
if (disableSwap) {
newValue = clamp(newValue, values[activeIndex - 1] || -Infinity, values[activeIndex + 1] || Infinity);
}
const previousValue = newValue;
newValue = setValueIndex({
values,
newValue,
index: activeIndex,
});
// Potentially swap the index if needed.
if (!(disableSwap && move)) {
activeIndex = newValue.indexOf(previousValue);
previousIndex.current = activeIndex;
}
}
return {
newValue,
activeIndex,
};
};
const handleTouchMove = useEventCallback((nativeEvent: TouchEvent | MouseEvent) => {
const finger = trackFinger(nativeEvent, touchId);
if (!finger) {
return;
}
moveCount.current += 1;
// Cancel move in case some other element consumed a mouseup event and it was not fired.
// @ts-ignore buttons doesn't not exists on touch event
if (nativeEvent.type === "mousemove" && nativeEvent.buttons === 0) {
// eslint-disable-next-line @typescript-eslint/no-use-before-define
handleTouchEnd(nativeEvent);
return;
}
const { newValue, activeIndex } = getFingerNewValue({
finger,
move: true,
getThumbIndex,
});
focusThumb({
sliderRef,
activeIndex,
setActive,
});
setValueState(newValue);
if (!dragging && moveCount.current > INTENTIONAL_DRAG_COUNT_THRESHOLD) {
setDragging(true);
}
if (handleChange && !areValuesEqual(newValue, valueDerived)) {
handleChange(nativeEvent, newValue, activeIndex);
}
});
const handleTouchEnd = useEventCallback((nativeEvent: TouchEvent | MouseEvent) => {
const finger = trackFinger(nativeEvent, touchId);
setDragging(false);
if (!finger) {
return;
}
const { newValue } = getFingerNewValue({
finger,
move: true,
getThumbIndex,
});
setActive(-1);
if (nativeEvent?.type === "touchend") {
setOpen(-1);
}
if (onChangeCommitted) {
onChangeCommitted(nativeEvent!, newValue);
}
touchId.current = undefined;
// eslint-disable-next-line @typescript-eslint/no-use-before-define
stopListening();
});
const handleTouchStart = useEventCallback((nativeEvent: TouchEvent) => {
if (disabled) {
return;
}
// If touch-action: none; is not supported we need to prevent the scroll manually.
if (!doesSupportTouchActionNone()) {
nativeEvent.preventDefault();
}
const touch = nativeEvent.changedTouches[0];
if (touch != null) {
// A number that uniquely identifies the current finger in the touch session.
touchId.current = touch.identifier;
}
const finger = trackFinger(nativeEvent, touchId);
if (finger !== false) {
const { newValue, activeIndex } = getFingerNewValue({
finger,
getThumbIndex,
});
focusThumb({
sliderRef,
activeIndex,
setActive,
});
setValueState(newValue);
if (handleChange && !areValuesEqual(newValue, valueDerived)) {
handleChange(nativeEvent, newValue, activeIndex);
}
}
moveCount.current = 0;
const doc = ownerDocument(sliderRef.current);
doc.addEventListener("touchmove", handleTouchMove, {
passive: true,
});
doc.addEventListener("touchend", handleTouchEnd, {
passive: true,
});
});
const stopListening = React.useCallback(() => {
const doc = ownerDocument(sliderRef.current);
doc.removeEventListener("mousemove", handleTouchMove);
doc.removeEventListener("mouseup", handleTouchEnd);
doc.removeEventListener("touchmove", handleTouchMove);
doc.removeEventListener("touchend", handleTouchEnd);
}, [handleTouchEnd, handleTouchMove]);
React.useEffect(() => {
const { current: slider } = sliderRef;
slider?.addEventListener("touchstart", handleTouchStart, {
passive: doesSupportTouchActionNone(),
});
return () => {
slider?.removeEventListener("touchstart", handleTouchStart);
stopListening();
};
}, [stopListening, handleTouchStart]);
React.useEffect(() => {
if (disabled) {
stopListening();
}
}, [disabled, stopListening]);
const createHandleMouseDown = (otherHandlers) => (event) => {
var _otherHandlers$onMous;
(_otherHandlers$onMous = otherHandlers.onMouseDown) == null || _otherHandlers$onMous.call(otherHandlers, event);
if (disabled) {
return;
}
if (event.defaultPrevented) {
return;
}
// Only handle left clicks
if (event.button !== 0) {
return;
}
// Avoid text selection
event.preventDefault();
const finger = trackFinger(event, touchId);
if (finger !== false) {
const { newValue, activeIndex } = getFingerNewValue({
finger,
getThumbIndex,
});
focusThumb({
sliderRef,
activeIndex,
setActive,
});
setValueState(newValue);
if (handleChange && !areValuesEqual(newValue, valueDerived)) {
handleChange(event, newValue, activeIndex);
}
}
moveCount.current = 0;
const doc = ownerDocument(sliderRef.current);
doc.addEventListener("mousemove", handleTouchMove, {
passive: true,
});
doc.addEventListener("mouseup", handleTouchEnd);
};
const trackOffset = valueToPercent(range ? values[0] : min, min, max);
const trackLeap = valueToPercent(values[values.length - 1], min, max) - trackOffset;
const getRootProps = (externalProps = {}) => {
const externalHandlers = extractEventHandlers(externalProps);
const ownEventHandlers = {
onMouseDown: createHandleMouseDown(externalHandlers || {}),
};
const mergedEventHandlers = _extends({}, externalHandlers, ownEventHandlers);
return _extends(
{},
externalProps,
{
ref: handleRef,
},
mergedEventHandlers,
);
};
const createHandleMouseOver = (otherHandlers) => (event) => {
var _otherHandlers$onMous2;
(_otherHandlers$onMous2 = otherHandlers.onMouseOver) == null || _otherHandlers$onMous2.call(otherHandlers, event);
const index = Number(event.currentTarget.getAttribute("data-index"));
setOpen(index);
};
const createHandleMouseLeave = (otherHandlers) => (event) => {
var _otherHandlers$onMous3;
(_otherHandlers$onMous3 = otherHandlers.onMouseLeave) == null || _otherHandlers$onMous3.call(otherHandlers, event);
setOpen(-1);
};
const getThumbProps = (externalProps = {}) => {
const externalHandlers = extractEventHandlers(externalProps);
const ownEventHandlers = {
onMouseOver: createHandleMouseOver(externalHandlers || {}),
onMouseLeave: createHandleMouseLeave(externalHandlers || {}),
};
return _extends({}, externalProps, externalHandlers, ownEventHandlers);
};
const getThumbStyle = (index) => {
return {
// So the non active thumb doesn't show its label on hover.
pointerEvents: active !== -1 && active !== index ? "none" : undefined,
};
};
const getHiddenInputProps = (externalProps = {}) => {
var _parameters$step;
const externalHandlers = extractEventHandlers(externalProps);
const ownEventHandlers = {
onChange: createHandleHiddenInputChange(externalHandlers || {}),
onFocus: createHandleHiddenInputFocus(externalHandlers || {}),
onBlur: createHandleHiddenInputBlur(externalHandlers || {}),
onKeyDown: createHandleHiddenInputKeyDown(externalHandlers || {}),
};
const mergedEventHandlers = _extends({}, externalHandlers, ownEventHandlers);
return _extends(
{
tabIndex,
"aria-labelledby": ariaLabelledby,
"aria-orientation": orientation,
"aria-valuemax": scale(max),
"aria-valuemin": scale(min),
name,
type: "range",
min: parameters.min,
max: parameters.max,
step:
parameters.step === null && parameters.marks
? "any"
: (_parameters$step = parameters.step) != null
? _parameters$step
: undefined,
disabled,
},
externalProps,
mergedEventHandlers,
{
style: _extends({}, visuallyHidden, {
direction: isRtl ? "rtl" : "ltr",
// So that VoiceOver's focus indicator matches the thumb's dimensions
width: "100%",
height: "100%",
}),
},
);
};
return {
active,
axis: axis,
axisProps,
dragging,
focusedThumbIndex,
getHiddenInputProps,
getRootProps,
getThumbProps,
marks: marks,
open,
range,
rootRef: handleRef,
trackLeap,
trackOffset,
values,
getThumbStyle,
};
}
const useUtilityClasses = (ownerState: SliderOwnerState) => {
const { disabled, dragging, marked, orientation, track, classes, color, size } = ownerState;
const slots = {
root: [
"root",
disabled && "disabled",
dragging && "dragging",
marked && "marked",
orientation === "vertical" && "vertical",
track === "inverted" && "trackInverted",
track === false && "trackFalse",
color && `color${capitalize(color)}`,
size && `size${capitalize(size)}`,
],
rail: ["rail"],
track: ["track"],
mark: ["mark"],
markActive: ["markActive"],
markLabel: ["markLabel"],
markLabelActive: ["markLabelActive"],
valueLabel: ["valueLabel"],
thumb: [
"thumb",
disabled && "disabled",
size && `thumbSize${capitalize(size)}`,
color && `thumbColor${capitalize(color)}`,
],
active: ["active"],
disabled: ["disabled"],
focusVisible: ["focusVisible"],
};
return composeClasses(slots, getSliderUtilityClass, classes);
};
const Forward = ({ children }) => children;
type CustomMuiSliderProps = SliderProps & { getThumbIndex: (
values: number[],
value: number,
min: number, // <- new, not passed to `findClosest` fn
max: number // <- also new
) => number };
const CustomMuiSlider = React.forwardRef(function Slider(
inputProps: CustomMuiSliderProps,
ref: React.Ref<HTMLSpanElement>,
) {
const props = useDefaultProps({ props: inputProps, name: "MuiSlider" });
const isRtl = useRtl();
const {
"aria-label": ariaLabel,
"aria-valuetext": ariaValuetext,
"aria-labelledby": ariaLabelledby,
// eslint-disable-next-line react/prop-types
component = "span",
components = {},
componentsProps = {},
color = "primary",
classes: classesProp,
className,
disableSwap = false,
disabled = false,
getAriaLabel,
getAriaValueText,
getThumbIndex,
marks: marksProp = false,
max = 100,
min = 0,
name,
onChange,
onChangeCommitted,
orientation = "horizontal",
shiftStep = 10,
size = "medium",
step = 1,
scale = Identity,
slotProps,
slots,
tabIndex,
track = "normal",
value: valueProp,
valueLabelDisplay = "off",
valueLabelFormat = Identity,
...other
} = props;
const ownerState = {
...props,
isRtl,
max,
min,
classes: classesProp,
disabled,
disableSwap,
getThumbIndex,
orientation,
marks: marksProp,
color,
size,
step,
shiftStep,
scale,
track,
valueLabelDisplay,
valueLabelFormat,
} as unknown as SliderOwnerState;
const {
axisProps,
getRootProps,
getHiddenInputProps,
getThumbProps,
open,
active,
axis,
focusedThumbIndex,
range,
dragging,
marks,
values,
trackOffset,
trackLeap,
getThumbStyle,
} = useCustomMuiSlider({ ...ownerState, rootRef: ref });
ownerState.marked = (marks as Mark[]).length > 0 && (marks as Mark[]).some((mark) => mark.label);
ownerState.dragging = dragging;
ownerState.focusedThumbIndex = focusedThumbIndex;
const classes = useUtilityClasses(ownerState);
// support both `slots` and `components` for backward compatibility
const RootSlot = slots?.root ?? components.Root ?? SliderRoot;
const RailSlot = slots?.rail ?? components.Rail ?? SliderRail;
const TrackSlot = slots?.track ?? components.Track ?? SliderTrack;
const ThumbSlot = slots?.thumb ?? components.Thumb ?? SliderThumb;
const ValueLabelSlot = slots?.valueLabel ?? components.ValueLabel ?? SliderValueLabel;
const MarkSlot = slots?.mark ?? components.Mark ?? SliderMark;
const MarkLabelSlot = slots?.markLabel ?? components.MarkLabel ?? SliderMarkLabel;
const InputSlot = slots?.input ?? components.Input ?? "input";
const rootSlotProps = slotProps?.root ?? componentsProps.root;
const railSlotProps = slotProps?.rail ?? componentsProps.rail;
const trackSlotProps = slotProps?.track ?? componentsProps.track;
const thumbSlotProps = slotProps?.thumb ?? componentsProps.thumb;
const valueLabelSlotProps = slotProps?.valueLabel ?? componentsProps.valueLabel;
const markSlotProps = slotProps?.mark ?? componentsProps.mark;
const markLabelSlotProps = slotProps?.markLabel ?? componentsProps.markLabel;
const inputSlotProps = slotProps?.input ?? componentsProps.input;
const rootProps = useSlotProps({
elementType: RootSlot,
getSlotProps: getRootProps,
externalSlotProps: rootSlotProps,
externalForwardedProps: other,
additionalProps: {
...(shouldSpreadAdditionalProps(RootSlot) && {
as: component,
}),
},
ownerState: {
...ownerState,
...asWithOwnerState(rootSlotProps)?.ownerState,
},
className: [classes.root, className],
});
const railProps = useSlotProps({
elementType: RailSlot,
externalSlotProps: railSlotProps,
ownerState,
className: classes.rail,
});
const trackProps = useSlotProps({
elementType: TrackSlot,
externalSlotProps: trackSlotProps,
additionalProps: {
style: {
...axisProps[axis].offset(trackOffset),
...axisProps[axis].leap(trackLeap),
},
},
ownerState: {
...ownerState,
...asWithOwnerState(trackSlotProps)?.ownerState,
},
className: classes.track,
});
const thumbProps = useSlotProps({
elementType: ThumbSlot,
getSlotProps: getThumbProps,
externalSlotProps: thumbSlotProps,
ownerState: {
...ownerState,
...asWithOwnerState(thumbSlotProps)?.ownerState,
},
className: classes.thumb,
});
const valueLabelProps = useSlotProps({
elementType: ValueLabelSlot,
externalSlotProps: valueLabelSlotProps,
ownerState: {
...ownerState,
...asWithOwnerState(valueLabelSlotProps)?.ownerState,
},
className: classes.valueLabel,
});
const markProps = useSlotProps({
elementType: MarkSlot,
externalSlotProps: markSlotProps,
ownerState,
className: classes.mark,
});
const markLabelProps = useSlotProps({
elementType: MarkLabelSlot,
externalSlotProps: markLabelSlotProps,
ownerState,
className: classes.markLabel,
});
const inputSliderProps = useSlotProps({
elementType: InputSlot,
getSlotProps: getHiddenInputProps,
externalSlotProps: inputSlotProps,
ownerState,
});
return (
<RootSlot {...rootProps}>
<RailSlot {...railProps} />
<TrackSlot {...trackProps} />
{(marks as Mark[])
.filter((mark) => mark.value >= min && mark.value <= max)
.map((mark, index) => {
const percent = valueToPercent(mark.value, min, max);
const style = axisProps[axis].offset(percent);
let markActive;
if (track === false) {
markActive = values.indexOf(mark.value) !== -1;
} else {
markActive =
(track === "normal" &&
(range
? mark.value >= values[0] && mark.value <= values[values.length - 1]
: mark.value <= values[0])) ||
(track === "inverted" &&
(range ? mark.value <= values[0] || mark.value >= values[values.length - 1] : mark.value >= values[0]));
}
return (
<React.Fragment key={index}>
<MarkSlot
data-index={index}
{...markProps}
{...(!isHostComponent(MarkSlot) && {
markActive,
})}
style={{ ...style, ...markProps.style }}
className={clsx(markProps.className, {
[classes.markActive]: markActive,
})}
/>
{mark.label != null ? (
<MarkLabelSlot
aria-hidden
data-index={index}
{...markLabelProps}
{...(!isHostComponent(MarkLabelSlot) && {
markLabelActive: markActive,
})}
style={{ ...style, ...markLabelProps.style }}
className={clsx(classes.markLabel, markLabelProps.className, {
[classes.markLabelActive]: markActive,
})}
>
{mark.label}
</MarkLabelSlot>
) : null}
</React.Fragment>
);
})}
{values.map((value, index) => {
const percent = valueToPercent(value, min, max);
const style = axisProps[axis].offset(percent);
const ValueLabelComponent = valueLabelDisplay === "off" ? Forward : ValueLabelSlot;
return (
/* TODO v6: Change component structure. It will help in avoiding the complicated React.cloneElement API added in SliderValueLabel component. Should be: Thumb -> Input, ValueLabel. Follow Joy UI's Slider structure. */
<ValueLabelComponent
key={index}
{...(!isHostComponent(ValueLabelComponent) && {
valueLabelFormat,
valueLabelDisplay,
value: typeof valueLabelFormat === "function" ? valueLabelFormat(scale(value), index) : valueLabelFormat,
index,
open: open === index || active === index || valueLabelDisplay === "on",
disabled,
})}
{...valueLabelProps}
>
<ThumbSlot
data-index={index}
{...thumbProps}
className={clsx(classes.thumb, thumbProps.className, {
[classes.active]: active === index,
[classes.focusVisible]: focusedThumbIndex === index,
})}
style={{
...style,
...getThumbStyle(index),
...thumbProps.style,
}}
>
<InputSlot
data-index={index}
aria-label={getAriaLabel ? getAriaLabel(index) : ariaLabel}
aria-valuenow={scale(value)}
aria-labelledby={ariaLabelledby}
aria-valuetext={getAriaValueText ? getAriaValueText(scale(value), index) : ariaValuetext}
value={values[index]}
{...inputSliderProps}
/>
</ThumbSlot>
</ValueLabelComponent>
);
})}
</RootSlot>
);
});
export default CustomMuiSlider;
// An example implementation of CustomMuiSlider.tsx using a function that assumes the middle thumb (index 1)
// is always preferred unless the cursor is actually touching the thumb controls on the ends.
// Padding distance in % from the position of thumbs 0 and 2 consider them as touched.
const TRIM_CONTROL_TOUCH_THRESHOLD = 1;
// Determine which thumb index should react to the touch/cursor
const getDesiredThumbIndex = (thumbValues: number[], activeValue: number, min: number, max: number) => {
if (thumbValues.length < 3) {
throw new Error("Thumb values must have at least 3 values");
}
// Calculate actual distance from activeValue to each thumb, then convert to % of total range
// This would be better to use boundingClientRects, this is good enough for now.
const distances = thumbValues.map((thumb) => (Math.abs(activeValue - thumb) / (max - min)) * 100);
const [distFrom0, _distFrom1, distFrom2] = distances;
if (distFrom0 <= TRIM_CONTROL_TOUCH_THRESHOLD) {
return 0;
} else if (distFrom2 <= TRIM_CONTROL_TOUCH_THRESHOLD) {
return 2;
}
return 1;
};
// Component
const TrimControlsScrubBar = (props: SliderProps) => <CustomMuiSlider
...props,
getThumbIndex={getDesiredThumbIndex}
/>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment