Last active
March 16, 2025 00:06
-
-
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
"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; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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