diff --git a/docs/pages/api-docs/slider.json b/docs/pages/api-docs/slider.json index 2a5b6756ad0703..73291bff59aff0 100644 --- a/docs/pages/api-docs/slider.json +++ b/docs/pages/api-docs/slider.json @@ -11,7 +11,6 @@ }, "default": "'primary'" }, - "component": { "type": { "name": "elementType" } }, "components": { "type": { "name": "shape", diff --git a/docs/translations/api-docs/slider/slider.json b/docs/translations/api-docs/slider/slider.json index 2eb768d99df918..e6fc0d9383f429 100644 --- a/docs/translations/api-docs/slider/slider.json +++ b/docs/translations/api-docs/slider/slider.json @@ -29,8 +29,7 @@ "track": "The track presentation:
- normal the track will render a bar representing the slider value. - inverted the track will render a bar representing the remaining slider value. - false the track will render without a bar.", "value": "The value of the slider. For ranged sliders, provide an array with two values.", "valueLabelDisplay": "Controls when the value label is displayed:
- auto the value label will display when the thumb is hovered or focused. - on will display persistently. - off will never display.", - "valueLabelFormat": "The format function the value label's value.
When a function is provided, it should have the following signature:
- {number} value The value label's value to format - {number} index The value label's index to format", - "component": "The component used for the root node. Either a string to use a HTML element or a component." + "valueLabelFormat": "The format function the value label's value.
When a function is provided, it should have the following signature:
- {number} value The value label's value to format - {number} index The value label's index to format" }, "classDescriptions": { "root": { "description": "Class name applied to the root element." }, diff --git a/packages/mui-base/src/SliderUnstyled/SliderUnstyled.d.ts b/packages/mui-base/src/SliderUnstyled/SliderUnstyled.d.ts index 6cf935123abb31..ffd3d1bd17fe06 100644 --- a/packages/mui-base/src/SliderUnstyled/SliderUnstyled.d.ts +++ b/packages/mui-base/src/SliderUnstyled/SliderUnstyled.d.ts @@ -1,216 +1,5 @@ -import { OverridableComponent, OverridableTypeMap, OverrideProps } from '@mui/types'; -import { SliderUnstyledClasses } from './sliderUnstyledClasses'; -import SliderValueLabelUnstyled from './SliderValueLabelUnstyled'; - -export interface SliderOwnerStateOverrides {} - -export interface Mark { - value: number; - label?: React.ReactNode; -} - -export interface ValueLabelProps extends React.HTMLAttributes { - children: React.ReactElement; - index: number; - open: boolean; - value: number; -} - -export interface SliderUnstyledComponentsPropsOverrides {} - -export interface SliderUnstyledTypeMap

{ - props: P & { - /** - * The label of the slider. - */ - 'aria-label'?: string; - /** - * The id of the element containing a label for the slider. - */ - 'aria-labelledby'?: string; - /** - * A string value that provides a user-friendly name for the current value of the slider. - */ - 'aria-valuetext'?: string; - /** - * Override or extend the styles applied to the component. - */ - classes?: Partial; - /** - * The components used for each slot inside the Slider. - * Either a string to use a HTML element or a component. - * @default {} - */ - components?: { - Root?: React.ElementType; - Track?: React.ElementType; - Rail?: React.ElementType; - Thumb?: React.ElementType; - Mark?: React.ElementType; - MarkLabel?: React.ElementType; - ValueLabel?: React.ElementType; - Input?: React.ElementType; - }; - /** - * The props used for each slot inside the Slider. - * @default {} - */ - componentsProps?: { - root?: React.HTMLAttributes & SliderUnstyledComponentsPropsOverrides; - track?: React.HTMLAttributes & SliderUnstyledComponentsPropsOverrides; - rail?: React.HTMLAttributes & SliderUnstyledComponentsPropsOverrides; - thumb?: React.HTMLAttributes & SliderUnstyledComponentsPropsOverrides; - mark?: React.HTMLAttributes & SliderUnstyledComponentsPropsOverrides; - markLabel?: React.HTMLAttributes & SliderUnstyledComponentsPropsOverrides; - valueLabel?: React.ComponentPropsWithRef & - SliderUnstyledComponentsPropsOverrides; - input?: React.HTMLAttributes & SliderUnstyledComponentsPropsOverrides; - }; - /** - * The default value. Use when the component is not controlled. - */ - defaultValue?: number | number[]; - /** - * If `true`, the component is disabled. - * @default false - */ - disabled?: boolean; - /** - * If `true`, the active thumb doesn't swap when moving pointer over a thumb while dragging another thumb. - * @default false - */ - disableSwap?: boolean; - /** - * Accepts a function which returns a string value that provides a user-friendly name for the thumb labels of the slider. - * This is important for screen reader users. - * @param {number} index The thumb label's index to format. - * @returns {string} - */ - getAriaLabel?: (index: number) => string; - /** - * Accepts a function which returns a string value that provides a user-friendly name for the current value of the slider. - * This is important for screen reader users. - * @param {number} value The thumb label's value to format. - * @param {number} index The thumb label's index to format. - * @returns {string} - */ - getAriaValueText?: (value: number, index: number) => string; - /** - * Indicates whether the theme context has rtl direction. It is set automatically. - * @default false - */ - isRtl?: boolean; - /** - * Marks indicate predetermined values to which the user can move the slider. - * If `true` the marks are spaced according the value of the `step` prop. - * If an array, it should contain objects with `value` and an optional `label` keys. - * @default false - */ - marks?: boolean | Mark[]; - /** - * The maximum allowed value of the slider. - * Should not be equal to min. - * @default 100 - */ - max?: number; - /** - * The minimum allowed value of the slider. - * Should not be equal to max. - * @default 0 - */ - min?: number; - /** - * Name attribute of the hidden `input` element. - */ - name?: string; - /** - * Callback function that is fired when the slider's value changed. - * - * @param {Event} event The event source of the callback. - * You can pull out the new value by accessing `event.target.value` (any). - * **Warning**: This is a generic event not a change event. - * @param {number | number[]} value The new value. - * @param {number} activeThumb Index of the currently moved thumb. - */ - onChange?: (event: Event, value: number | number[], activeThumb: number) => void; - /** - * Callback function that is fired when the `mouseup` is triggered. - * - * @param {React.SyntheticEvent | Event} event The event source of the callback. **Warning**: This is a generic event not a change event. - * @param {number | number[]} value The new value. - */ - onChangeCommitted?: (event: React.SyntheticEvent | Event, value: number | number[]) => void; - /** - * The component orientation. - * @default 'horizontal' - */ - orientation?: 'horizontal' | 'vertical'; - /** - * A transformation function, to change the scale of the slider. - * @default (x) => x - */ - scale?: (value: number) => number; - /** - * The granularity with which the slider can step through values. (A "discrete" slider.) - * The `min` prop serves as the origin for the valid values. - * We recommend (max - min) to be evenly divisible by the step. - * - * When step is `null`, the thumb can only be slid onto marks provided with the `marks` prop. - * @default 1 - */ - step?: number | null; - /** - * Tab index attribute of the hidden `input` element. - */ - tabIndex?: number; - /** - * The track presentation: - * - * - `normal` the track will render a bar representing the slider value. - * - `inverted` the track will render a bar representing the remaining slider value. - * - `false` the track will render without a bar. - * @default 'normal' - */ - track?: 'normal' | false | 'inverted'; - /** - * The value of the slider. - * For ranged sliders, provide an array with two values. - */ - value?: number | number[]; - /** - * Controls when the value label is displayed: - * - * - `auto` the value label will display when the thumb is hovered or focused. - * - `on` will display persistently. - * - `off` will never display. - * @default 'off' - */ - valueLabelDisplay?: 'on' | 'auto' | 'off'; - /** - * The format function the value label's value. - * - * When a function is provided, it should have the following signature: - * - * - {number} value The value label's value to format - * - {number} index The value label's index to format - * @default (x) => x - */ - valueLabelFormat?: string | ((value: number, index: number) => React.ReactNode); - }; - defaultComponent: D; -} - -/** - * Utility to create component types that inherit props from SliderUnstyled. - */ -export interface ExtendSliderUnstyledTypeMap { - props: M['props'] & SliderUnstyledTypeMap['props']; - defaultComponent: M['defaultComponent']; -} - -export type ExtendSliderUnstyled = OverridableComponent< - ExtendSliderUnstyledTypeMap ->; +import { OverridableComponent } from '@mui/types'; +import { SliderUnstyledTypeMap } from './SliderUnstyledProps'; /** * @@ -224,9 +13,4 @@ export type ExtendSliderUnstyled = OverridableComp */ declare const SliderUnstyled: OverridableComponent; -export type SliderUnstyledProps< - D extends React.ElementType = SliderUnstyledTypeMap['defaultComponent'], - P = {}, -> = OverrideProps, D>; - export default SliderUnstyled; diff --git a/packages/mui-base/src/SliderUnstyled/SliderUnstyled.js b/packages/mui-base/src/SliderUnstyled/SliderUnstyled.js index a6f0c241301351..44e07796ebb192 100644 --- a/packages/mui-base/src/SliderUnstyled/SliderUnstyled.js +++ b/packages/mui-base/src/SliderUnstyled/SliderUnstyled.js @@ -1,156 +1,16 @@ import * as React from 'react'; import PropTypes from 'prop-types'; import clsx from 'clsx'; -import { - chainPropTypes, - unstable_useIsFocusVisible as useIsFocusVisible, - unstable_useEnhancedEffect as useEnhancedEffect, - unstable_ownerDocument as ownerDocument, - unstable_useEventCallback as useEventCallback, - unstable_useForkRef as useForkRef, - unstable_useControlled as useControlled, - visuallyHidden, -} from '@mui/utils'; +import { chainPropTypes } from '@mui/utils'; +import appendOwnerState from '../utils/appendOwnerState'; import isHostComponent from '../utils/isHostComponent'; import composeClasses from '../composeClasses'; import { getSliderUtilityClass } from './sliderUnstyledClasses'; import SliderValueLabelUnstyled from './SliderValueLabelUnstyled'; - -const INTENTIONAL_DRAG_COUNT_THRESHOLD = 2; - -function asc(a, b) { - return a - b; -} - -function clamp(value, min, max) { - if (value == null) { - return min; - } - return Math.min(Math.max(min, value), max); -} - -function findClosest(values, currentValue) { - const { index: closestIndex } = 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); - return closestIndex; -} - -function trackFinger(event, touchId) { - if (touchId.current !== undefined && event.changedTouches) { - for (let i = 0; i < event.changedTouches.length; i += 1) { - const touch = event.changedTouches[i]; - if (touch.identifier === touchId.current) { - return { - x: touch.clientX, - y: touch.clientY, - }; - } - } - - return false; - } - - return { - x: event.clientX, - y: event.clientY, - }; -} - -function valueToPercent(value, min, max) { - return ((value - min) * 100) / (max - min); -} - -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 }) { - const doc = ownerDocument(sliderRef.current); - if ( - !sliderRef.current.contains(doc.activeElement) || - Number(doc.activeElement.getAttribute('data-index')) !== activeIndex - ) { - sliderRef.current.querySelector(`[type="range"][data-index="${activeIndex}"]`).focus(); - } - - if (setActive) { - setActive(activeIndex); - } -} - -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}%` }), - }, -}; +import useSlider, { valueToPercent } from './useSlider'; const Identity = (x) => x; -// 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; -} - const useUtilityClasses = (ownerState) => { const { disabled, dragging, marked, orientation, track, classes } = ownerState; @@ -185,12 +45,10 @@ const Forward = ({ children }) => children; const SliderUnstyled = React.forwardRef(function SliderUnstyled(props, ref) { const { 'aria-label': ariaLabel, - 'aria-labelledby': ariaLabelledby, 'aria-valuetext': ariaValuetext, className, - component = 'span', + component, classes: classesProp, - defaultValue, disableSwap = false, disabled = false, getAriaLabel, @@ -216,409 +74,14 @@ const SliderUnstyled = React.forwardRef(function SliderUnstyled(props, ref) { ...other } = props; - const touchId = React.useRef(); - // 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 ?? 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-org/material-ui/issues/13485#issuecomment-676048492 - // Clone the event to not override `target` of the original event. - const nativeEvent = event.nativeEvent || event; - 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) => 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 { - isFocusVisibleRef, - onBlur: handleBlurVisible, - onFocus: handleFocusVisible, - ref: focusVisibleRef, - } = useIsFocusVisible(); - const [focusVisible, setFocusVisible] = React.useState(-1); - - const sliderRef = React.useRef(); - const handleFocusRef = useForkRef(focusVisibleRef, sliderRef); - const handleRef = useForkRef(ref, handleFocusRef); - - const handleFocus = (event) => { - const index = Number(event.currentTarget.getAttribute('data-index')); - handleFocusVisible(event); - if (isFocusVisibleRef.current === true) { - setFocusVisible(index); - } - setOpen(index); - }; - const handleBlur = (event) => { - handleBlurVisible(event); - if (isFocusVisibleRef.current === false) { - setFocusVisible(-1); - } - setOpen(-1); - }; - const handleMouseOver = useEventCallback((event) => { - const index = Number(event.currentTarget.getAttribute('data-index')); - setOpen(index); - }); - const handleMouseLeave = useEventCallback(() => { - setOpen(-1); - }); - - useEnhancedEffect(() => { - if (disabled && sliderRef.current.contains(document.activeElement)) { - // This is necessary because Firefox and Safari will keep focus - // on a disabled element: - // https://codesandbox.io/s/mui-pr-22247-forked-h151h?file=/src/App.js - document.activeElement.blur(); - } - }, [disabled]); - - if (disabled && active !== -1) { - setActive(-1); - } - if (disabled && focusVisible !== -1) { - setFocusVisible(-1); - } - - const handleHiddenInputChange = (event) => { - const index = Number(event.currentTarget.getAttribute('data-index')); - const value = values[index]; - const marksValues = marks.map((mark) => mark.value); - const marksIndex = marksValues.indexOf(value); - - let newValue = event.target.valueAsNumber; - - if (marks && step == null) { - newValue = newValue < value ? marksValues[marksIndex - 1] : marksValues[marksIndex + 1]; - } - - newValue = clamp(newValue, min, max); - - if (marks && step == null) { - const markValues = marks.map((mark) => mark.value); - const currentMarkIndex = markValues.indexOf(values[index]); - - newValue = - newValue < values[index] - ? markValues[currentMarkIndex - 1] - : markValues[currentMarkIndex + 1]; - } - - 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); - setFocusVisible(index); - - if (handleChange) { - handleChange(event, newValue, index); - } - - if (onChangeCommitted) { - onChangeCommitted(event, newValue); - } - }; - - const previousIndex = React.useRef(); - let axis = orientation; - if (isRtl && orientation === 'horizontal') { - axis += '-reverse'; - } - - const getFingerNewValue = ({ finger, move = false, values: values2 }) => { - const { current: slider } = sliderRef; - const { width, height, bottom, left } = slider.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 marksValues = marks.map((mark) => mark.value); - const closestIndex = findClosest(marksValues, newValue); - newValue = marksValues[closestIndex]; - } - - newValue = clamp(newValue, min, max); - let activeIndex = 0; - - if (range) { - if (!move) { - activeIndex = findClosest(values2, newValue); - } else { - activeIndex = previousIndex.current; - } - - // Bound the new value to the thumb's neighbours. - if (disableSwap) { - newValue = clamp( - newValue, - values2[activeIndex - 1] || -Infinity, - values2[activeIndex + 1] || Infinity, - ); - } - - const previousValue = newValue; - newValue = setValueIndex({ - values: values2, - 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) => { - 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. - 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, - values, - }); - - focusThumb({ sliderRef, activeIndex, setActive }); - setValueState(newValue); - - if (!dragging && moveCount.current > INTENTIONAL_DRAG_COUNT_THRESHOLD) { - setDragging(true); - } - - if (handleChange) { - handleChange(nativeEvent, newValue, activeIndex); - } - }); - - const handleTouchEnd = useEventCallback((nativeEvent) => { - const finger = trackFinger(nativeEvent, touchId); - setDragging(false); - - if (!finger) { - return; - } - - const { newValue } = getFingerNewValue({ finger, values }); - - 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) => { - // 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); - const { newValue, activeIndex } = getFingerNewValue({ finger, values }); - focusThumb({ sliderRef, activeIndex, setActive }); - - setValueState(newValue); - - if (handleChange) { - handleChange(nativeEvent, newValue, activeIndex); - } - - moveCount.current = 0; - const doc = ownerDocument(sliderRef.current); - doc.addEventListener('touchmove', handleTouchMove); - doc.addEventListener('touchend', handleTouchEnd); - }); - - 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, { - passive: doesSupportTouchActionNone(), - }); - - stopListening(); - }; - }, [stopListening, handleTouchStart]); - - React.useEffect(() => { - if (disabled) { - stopListening(); - } - }, [disabled, stopListening]); - - const handleMouseDown = useEventCallback((event) => { - if (onMouseDown) { - onMouseDown(event); - } - - // Only handle left clicks - if (event.button !== 0) { - return; - } - - // Avoid text selection - event.preventDefault(); - const finger = trackFinger(event, touchId); - const { newValue, activeIndex } = getFingerNewValue({ finger, values }); - focusThumb({ sliderRef, activeIndex, setActive }); - - setValueState(newValue); - - if (handleChange) { - handleChange(event, newValue, activeIndex); - } - - moveCount.current = 0; - const doc = ownerDocument(sliderRef.current); - doc.addEventListener('mousemove', handleTouchMove); - doc.addEventListener('mouseup', handleTouchEnd); - }); - - const trackOffset = valueToPercent(range ? values[0] : min, min, max); - const trackLeap = valueToPercent(values[values.length - 1], min, max) - trackOffset; - const trackStyle = { - ...axisProps[axis].offset(trackOffset), - ...axisProps[axis].leap(trackLeap), - }; - - const Root = components.Root || component; - const rootProps = componentsProps.root || {}; - - const Rail = components.Rail || 'span'; - const railProps = componentsProps.rail || {}; - - const Track = components.Track || 'span'; - const trackProps = componentsProps.track || {}; - - const Thumb = components.Thumb || 'span'; - const thumbProps = componentsProps.thumb || {}; - - const ValueLabel = components.ValueLabel || SliderValueLabelUnstyled; - const valueLabelProps = componentsProps.valueLabel || {}; - - const Mark = components.Mark || 'span'; - const markProps = componentsProps.mark || {}; - - const MarkLabel = components.MarkLabel || 'span'; - const markLabelProps = componentsProps.markLabel || {}; - - const Input = components.Input || 'input'; - const inputProps = componentsProps.input || {}; - // all props with defaults // consider extracting to hook an reusing the lint rule for the varints const ownerState = { ...props, + mark: marksProp, classes: classesProp, disabled, - dragging, isRtl, - marked: marks.length > 0 && marks.some((mark) => mark.label), max, min, orientation, @@ -629,32 +92,66 @@ const SliderUnstyled = React.forwardRef(function SliderUnstyled(props, ref) { valueLabelFormat, }; + const { + axisProps, + getRootProps, + getHiddenInputProps, + getThumbProps, + open, + active, + axis, + range, + focusVisible, + dragging, + marks, + values, + trackOffset, + trackLeap, + } = useSlider({ ...ownerState, ref }); + + ownerState.marked = marks.length > 0 && marks.some((mark) => mark.label); + ownerState.dragging = dragging; + + const Root = component ?? components.Root ?? 'span'; + const rootProps = appendOwnerState(Root, { ...other, ...componentsProps.root }, ownerState); + + const Rail = components.Rail ?? 'span'; + const railProps = appendOwnerState(Rail, componentsProps.rail, ownerState); + + const Track = components.Track ?? 'span'; + const trackProps = appendOwnerState(Track, componentsProps.track, ownerState); + const trackStyle = { + ...axisProps[axis].offset(trackOffset), + ...axisProps[axis].leap(trackLeap), + }; + + const Thumb = components.Thumb ?? 'span'; + const thumbProps = appendOwnerState(Thumb, componentsProps.thumb, ownerState); + + const ValueLabel = components.ValueLabel ?? SliderValueLabelUnstyled; + const valueLabelProps = appendOwnerState(ValueLabel, componentsProps.valueLabel, ownerState); + + const Mark = components.Mark ?? 'span'; + const markProps = appendOwnerState(Mark, componentsProps.mark, ownerState); + + const MarkLabel = components.MarkLabel ?? 'span'; + const markLabelProps = appendOwnerState(MarkLabel, componentsProps.markLabel, ownerState); + + const Input = components.Input || 'input'; + const inputProps = appendOwnerState(Input, componentsProps.input, ownerState); + const hiddenInputProps = getHiddenInputProps(); + const classes = useUtilityClasses(ownerState); return ( - + @@ -683,7 +180,6 @@ const SliderUnstyled = React.forwardRef(function SliderUnstyled(props, ref) { data-index={index} {...markProps} {...(!isHostComponent(Mark) && { - ownerState: { ...ownerState, ...markProps.ownerState }, markActive, })} style={{ ...style, ...markProps.style }} @@ -697,12 +193,8 @@ const SliderUnstyled = React.forwardRef(function SliderUnstyled(props, ref) { data-index={index} {...markLabelProps} {...(!isHostComponent(MarkLabel) && { - ownerState: { - ...ownerState, - ...markLabelProps.ownerState, - }, + markLabelActive: markActive, })} - markLabelActive={markActive} style={{ ...style, ...markLabelProps.style }} className={clsx(classes.markLabel, markLabelProps.className, { [classes.markLabelActive]: markActive, @@ -723,27 +215,24 @@ const SliderUnstyled = React.forwardRef(function SliderUnstyled(props, ref) { return ( diff --git a/packages/mui-base/src/SliderUnstyled/SliderUnstyled.test.js b/packages/mui-base/src/SliderUnstyled/SliderUnstyled.test.js index c38f8e9fd0b216..a31cd9a98fcb2c 100644 --- a/packages/mui-base/src/SliderUnstyled/SliderUnstyled.test.js +++ b/packages/mui-base/src/SliderUnstyled/SliderUnstyled.test.js @@ -2,7 +2,13 @@ import SliderUnstyled, { sliderUnstyledClasses as classes } from '@mui/base/Slid import { expect } from 'chai'; import * as React from 'react'; import { spy, stub } from 'sinon'; -import { createRenderer, createMount, describeConformance, fireEvent, screen } from 'test/utils'; +import { + createRenderer, + createMount, + describeConformanceUnstyled, + fireEvent, + screen, +} from 'test/utils'; describe('', () => { before(function beforeHook() { @@ -14,18 +20,28 @@ describe('', () => { const mount = createMount(); const { render } = createRenderer(); - describeConformance(, () => ({ + describeConformanceUnstyled(, () => ({ classes, inheritComponent: 'span', - mount, render, + mount, refInstanceof: window.HTMLSpanElement, - testComponentPropWith: 'span', - skip: [ - 'themeDefaultProps', // unstyled - 'themeStyleOverrides', // unstyled - 'themeVariants', // unstyled - ], + testComponentPropWith: 'div', + muiName: 'MuiSlider', + slots: { + root: { + expectedClassName: classes.root, + }, + thumb: { + expectedClassName: classes.thumb, + }, + track: { + expectedClassName: classes.track, + }, + rail: { + expectedClassName: classes.rail, + }, + }, })); it('forwards style props on the Root component', () => { diff --git a/packages/mui-base/src/SliderUnstyled/SliderUnstyledProps.ts b/packages/mui-base/src/SliderUnstyled/SliderUnstyledProps.ts new file mode 100644 index 00000000000000..533ffbe10deb0f --- /dev/null +++ b/packages/mui-base/src/SliderUnstyled/SliderUnstyledProps.ts @@ -0,0 +1,220 @@ +import { OverridableComponent, OverridableTypeMap, OverrideProps } from '@mui/types'; +import { SliderUnstyledClasses } from './sliderUnstyledClasses'; +import SliderValueLabelUnstyled from './SliderValueLabelUnstyled'; + +export interface SliderOwnerStateOverrides {} + +export interface Mark { + value: number; + label?: React.ReactNode; +} + +export interface ValueLabelProps extends React.HTMLAttributes { + children: React.ReactElement; + index: number; + open: boolean; + value: number; +} + +export interface SliderUnstyledComponentsPropsOverrides {} + +export interface SliderUnstyledTypeMap

{ + props: P & { + /** + * The label of the slider. + */ + 'aria-label'?: string; + /** + * The id of the element containing a label for the slider. + */ + 'aria-labelledby'?: string; + /** + * A string value that provides a user-friendly name for the current value of the slider. + */ + 'aria-valuetext'?: string; + /** + * Override or extend the styles applied to the component. + */ + classes?: Partial; + /** + * The components used for each slot inside the Slider. + * Either a string to use a HTML element or a component. + * @default {} + */ + components?: { + Root?: React.ElementType; + Track?: React.ElementType; + Rail?: React.ElementType; + Thumb?: React.ElementType; + Mark?: React.ElementType; + MarkLabel?: React.ElementType; + ValueLabel?: React.ElementType; + Input?: React.ElementType; + }; + /** + * The props used for each slot inside the Slider. + * @default {} + */ + componentsProps?: { + root?: React.ComponentPropsWithRef<'span'> & SliderUnstyledComponentsPropsOverrides; + track?: React.ComponentPropsWithRef<'span'> & SliderUnstyledComponentsPropsOverrides; + rail?: React.ComponentPropsWithRef<'span'> & SliderUnstyledComponentsPropsOverrides; + thumb?: React.ComponentPropsWithRef<'span'> & SliderUnstyledComponentsPropsOverrides; + mark?: React.ComponentPropsWithRef<'span'> & SliderUnstyledComponentsPropsOverrides; + markLabel?: React.ComponentPropsWithRef<'span'> & SliderUnstyledComponentsPropsOverrides; + valueLabel?: React.ComponentPropsWithRef & + SliderUnstyledComponentsPropsOverrides; + input?: React.ComponentPropsWithRef<'input'> & SliderUnstyledComponentsPropsOverrides; + }; + /** + * The default value. Use when the component is not controlled. + */ + defaultValue?: number | number[]; + /** + * If `true`, the component is disabled. + * @default false + */ + disabled?: boolean; + /** + * If `true`, the active thumb doesn't swap when moving pointer over a thumb while dragging another thumb. + * @default false + */ + disableSwap?: boolean; + /** + * Accepts a function which returns a string value that provides a user-friendly name for the thumb labels of the slider. + * This is important for screen reader users. + * @param {number} index The thumb label's index to format. + * @returns {string} + */ + getAriaLabel?: (index: number) => string; + /** + * Accepts a function which returns a string value that provides a user-friendly name for the current value of the slider. + * This is important for screen reader users. + * @param {number} value The thumb label's value to format. + * @param {number} index The thumb label's index to format. + * @returns {string} + */ + getAriaValueText?: (value: number, index: number) => string; + /** + * Indicates whether the theme context has rtl direction. It is set automatically. + * @default false + */ + isRtl?: boolean; + /** + * Marks indicate predetermined values to which the user can move the slider. + * If `true` the marks are spaced according the value of the `step` prop. + * If an array, it should contain objects with `value` and an optional `label` keys. + * @default false + */ + marks?: boolean | Mark[]; + /** + * The maximum allowed value of the slider. + * Should not be equal to min. + * @default 100 + */ + max?: number; + /** + * The minimum allowed value of the slider. + * Should not be equal to max. + * @default 0 + */ + min?: number; + /** + * Name attribute of the hidden `input` element. + */ + name?: string; + /** + * Callback function that is fired when the slider's value changed. + * + * @param {Event} event The event source of the callback. + * You can pull out the new value by accessing `event.target.value` (any). + * **Warning**: This is a generic event not a change event. + * @param {number | number[]} value The new value. + * @param {number} activeThumb Index of the currently moved thumb. + */ + onChange?: (event: Event, value: number | number[], activeThumb: number) => void; + /** + * Callback function that is fired when the `mouseup` is triggered. + * + * @param {React.SyntheticEvent | Event} event The event source of the callback. **Warning**: This is a generic event not a change event. + * @param {number | number[]} value The new value. + */ + onChangeCommitted?: (event: React.SyntheticEvent | Event, value: number | number[]) => void; + /** + * The component orientation. + * @default 'horizontal' + */ + orientation?: 'horizontal' | 'vertical'; + /** + * A transformation function, to change the scale of the slider. + * @default (x) => x + */ + scale?: (value: number) => number; + /** + * The granularity with which the slider can step through values. (A "discrete" slider.) + * The `min` prop serves as the origin for the valid values. + * We recommend (max - min) to be evenly divisible by the step. + * + * When step is `null`, the thumb can only be slid onto marks provided with the `marks` prop. + * @default 1 + */ + step?: number | null; + /** + * Tab index attribute of the hidden `input` element. + */ + tabIndex?: number; + /** + * The track presentation: + * + * - `normal` the track will render a bar representing the slider value. + * - `inverted` the track will render a bar representing the remaining slider value. + * - `false` the track will render without a bar. + * @default 'normal' + */ + track?: 'normal' | false | 'inverted'; + /** + * The value of the slider. + * For ranged sliders, provide an array with two values. + */ + value?: number | number[]; + /** + * Controls when the value label is displayed: + * + * - `auto` the value label will display when the thumb is hovered or focused. + * - `on` will display persistently. + * - `off` will never display. + * @default 'off' + */ + valueLabelDisplay?: 'on' | 'auto' | 'off'; + /** + * The format function the value label's value. + * + * When a function is provided, it should have the following signature: + * + * - {number} value The value label's value to format + * - {number} index The value label's index to format + * @default (x) => x + */ + valueLabelFormat?: string | ((value: number, index: number) => React.ReactNode); + }; + defaultComponent: D; +} + +/** + * Utility to create component types that inherit props from SliderUnstyled. + */ +export interface ExtendSliderUnstyledTypeMap { + props: M['props'] & SliderUnstyledTypeMap['props']; + defaultComponent: M['defaultComponent']; +} + +export type ExtendSliderUnstyled = OverridableComponent< + ExtendSliderUnstyledTypeMap +>; + +type SliderUnstyledProps< + D extends React.ElementType = SliderUnstyledTypeMap['defaultComponent'], + P = {}, +> = OverrideProps, D>; + +export default SliderUnstyledProps; diff --git a/packages/mui-base/src/SliderUnstyled/index.d.ts b/packages/mui-base/src/SliderUnstyled/index.d.ts index f7141e5cf29460..f6a8dad8b6aa0b 100644 --- a/packages/mui-base/src/SliderUnstyled/index.d.ts +++ b/packages/mui-base/src/SliderUnstyled/index.d.ts @@ -4,5 +4,11 @@ export * from './SliderUnstyled'; export { default as SliderValueLabelUnstyled } from './SliderValueLabelUnstyled'; export * from './SliderValueLabelUnstyled'; +export { default as useSlider } from './useSlider'; +export * from './useSlider'; + +export { default as SliderUnstyledProps } from './SliderUnstyledProps'; +export * from './SliderUnstyledProps'; + export { default as sliderUnstyledClasses } from './sliderUnstyledClasses'; export * from './sliderUnstyledClasses'; diff --git a/packages/mui-base/src/SliderUnstyled/index.js b/packages/mui-base/src/SliderUnstyled/index.js index fd8c7f0ef356d2..8a215cc62ada6f 100644 --- a/packages/mui-base/src/SliderUnstyled/index.js +++ b/packages/mui-base/src/SliderUnstyled/index.js @@ -1,4 +1,5 @@ export { default } from './SliderUnstyled'; export { default as SliderValueLabelUnstyled } from './SliderValueLabelUnstyled'; export { default as sliderUnstyledClasses } from './sliderUnstyledClasses'; +export { default as useSlider } from './useSlider'; export * from './sliderUnstyledClasses'; diff --git a/packages/mui-base/src/SliderUnstyled/useSlider.ts b/packages/mui-base/src/SliderUnstyled/useSlider.ts new file mode 100644 index 00000000000000..f07b55abeccbb5 --- /dev/null +++ b/packages/mui-base/src/SliderUnstyled/useSlider.ts @@ -0,0 +1,701 @@ +import * as React from 'react'; +import { + unstable_useIsFocusVisible as useIsFocusVisible, + unstable_useEnhancedEffect as useEnhancedEffect, + unstable_ownerDocument as ownerDocument, + unstable_useEventCallback as useEventCallback, + unstable_useForkRef as useForkRef, + unstable_useControlled as useControlled, + visuallyHidden, +} from '@mui/utils'; +import SliderUnstyledProps from './SliderUnstyledProps'; + +interface Mark { + value: number; + label?: React.ReactNode; +} + +export interface UseSliderProps { + ref: React.Ref; + 'aria-labelledby'?: SliderUnstyledProps['aria-labelledby']; + defaultValue?: SliderUnstyledProps['defaultValue']; + disabled?: SliderUnstyledProps['disabled']; + disableSwap?: SliderUnstyledProps['disableSwap']; + isRtl?: SliderUnstyledProps['isRtl']; + marks?: SliderUnstyledProps['marks']; + max?: SliderUnstyledProps['max']; + min?: SliderUnstyledProps['min']; + name?: SliderUnstyledProps['name']; + onChange?: SliderUnstyledProps['onChange']; + onChangeCommitted?: SliderUnstyledProps['onChangeCommitted']; + orientation?: SliderUnstyledProps['orientation']; + scale?: SliderUnstyledProps['scale']; + step?: SliderUnstyledProps['step']; + tabIndex?: SliderUnstyledProps['tabIndex']; + value?: SliderUnstyledProps['value']; +} + +const INTENTIONAL_DRAG_COUNT_THRESHOLD = 2; + +function asc(a: number, b: number) { + return a - b; +} + +function clamp(value: number, min: number, max: number) { + if (value == null) { + return min; + } + return Math.min(Math.max(min, value), max); +} + +function findClosest(values: number[], currentValue: number) { + const { index: closestIndex } = + values.reduce<{ distance: number; index: number } | null>( + (acc, value: number, index: number) => { + const distance = Math.abs(currentValue - value); + + if (acc === null || distance < acc.distance || distance === acc.distance) { + return { + distance, + index, + }; + } + + return acc; + }, + null, + ) ?? {}; + return closestIndex; +} + +// TODO: Set the correct event type +function trackFinger(event: any, touchId: React.RefObject) { + if (touchId.current !== undefined && event.changedTouches) { + for (let i = 0; i < event.changedTouches.length; i += 1) { + const touch = event.changedTouches[i]; + if (touch.identifier === touchId.current) { + return { + x: touch.clientX, + y: touch.clientY, + }; + } + } + + return false; + } + + return { + x: event.clientX, + y: event.clientY, + }; +} + +export function valueToPercent(value: number, min: number, max: number) { + return ((value - min) * 100) / (max - min); +} + +function percentToValue(percent: number, min: number, max: number) { + return (max - min) * percent + min; +} + +function getDecimalPrecision(num: number) { + // 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: number, step: number, min: number) { + const nearest = Math.round((value - min) / step) * step + min; + return Number(nearest.toFixed(getDecimalPrecision(step))); +} + +function setValueIndex({ + values, + newValue, + index, +}: { + values: number[]; + newValue: number; + index: number; +}) { + const output = values.slice(); + output[index] = newValue; + return output.sort(asc); +} + +function focusThumb({ + sliderRef, + activeIndex, + setActive, +}: { + sliderRef: React.RefObject; + activeIndex: number; + setActive?: (num: number) => void; +}) { + const doc = ownerDocument(sliderRef.current); + if ( + !sliderRef.current?.contains(doc.activeElement) || + Number(doc?.activeElement?.getAttribute('data-index')) !== activeIndex + ) { + // @ts-ignore TODO: Property focus does not exists on type string + sliderRef.current?.querySelector(`[type="range"][data-index="${activeIndex}"]`).focus(); + } + + if (setActive) { + setActive(activeIndex); + } +} + +const axisProps = { + horizontal: { + offset: (percent: number) => ({ left: `${percent}%` }), + leap: (percent: number) => ({ width: `${percent}%` }), + }, + 'horizontal-reverse': { + offset: (percent: number) => ({ right: `${percent}%` }), + leap: (percent: number) => ({ width: `${percent}%` }), + }, + vertical: { + offset: (percent: number) => ({ bottom: `${percent}%` }), + leap: (percent: number) => ({ height: `${percent}%` }), + }, +}; + +export const Identity = (x: any) => x; + +// 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: any; +function doesSupportTouchActionNone() { + if (cachedSupportsTouchActionNone === undefined) { + if (typeof CSS !== 'undefined' && typeof CSS.supports === 'function') { + cachedSupportsTouchActionNone = CSS.supports('touch-action', 'none'); + } else { + cachedSupportsTouchActionNone = true; + } + } + return cachedSupportsTouchActionNone; +} + +export default function useSlider(props: UseSliderProps) { + const { + ref, + 'aria-labelledby': ariaLabelledby, + defaultValue, + disableSwap = false, + disabled = false, + marks: marksProp = false, + max = 100, + min = 0, + name, + onChange, + onChangeCommitted, + orientation = 'horizontal', + scale = Identity, + step = 1, + tabIndex, + value: valueProp, + isRtl = false, + } = props; + + const touchId = React.useRef(); + // 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 ?? min, + name: 'Slider', + }); + + const handleChange = + onChange && + ((event: Event | React.SyntheticEvent, value: number | number[], thumbIndex: number) => { + // Redefine target to allow name and value to be read. + // This allows seamless integration with the most popular form libraries. + // https://github.com/mui-org/material-ui/issues/13485#issuecomment-676048492 + // Clone the event to not override `target` of the original event. + // @ts-ignore nativeEvent does not exists on Event + const nativeEvent = event.nativeEvent || event; + // @ts-ignore TODO: check this again + const clonedEvent = new nativeEvent.constructor(nativeEvent.type, nativeEvent); + + Object.defineProperty(clonedEvent, 'target', { + writable: true, + value: { value, name }, + }); + + // @ts-ignore TODO: value could be undefined? + onChange(clonedEvent, value, thumbIndex); + }); + + const range = Array.isArray(valueDerived); + let values = range ? valueDerived.slice().sort(asc) : [valueDerived]; + values = values.map((value) => 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 Mark[]).map((mark: Mark) => mark.value); + + const { + isFocusVisibleRef, + onBlur: handleBlurVisible, + onFocus: handleFocusVisible, + ref: focusVisibleRef, + } = useIsFocusVisible(); + const [focusVisible, setFocusVisible] = React.useState(-1); + + const sliderRef = React.useRef(); + const handleFocusRef = useForkRef(focusVisibleRef, sliderRef); + const handleRef = useForkRef(ref, handleFocusRef); + + const createHandleHiddenInputFocus = + (otherHandlers: Record>) => (event: React.FocusEvent) => { + const index = Number(event.currentTarget.getAttribute('data-index')); + handleFocusVisible(event); + if (isFocusVisibleRef.current === true) { + setFocusVisible(index); + } + setOpen(index); + otherHandlers?.onFocus?.(event); + }; + const createHandleHidenInputBlur = + (otherHandlers: Record>) => (event: React.FocusEvent) => { + handleBlurVisible(event); + if (isFocusVisibleRef.current === false) { + setFocusVisible(-1); + } + setOpen(-1); + otherHandlers?.onBlur?.(event); + }; + + useEnhancedEffect(() => { + if (disabled && sliderRef.current!.contains(document.activeElement)) { + // This is necessary because Firefox and Safari will keep focus + // on a disabled element: + // https://codesandbox.io/s/mui-pr-22247-forked-h151h?file=/src/App.js + // @ts-ignore + document.activeElement?.blur(); + } + }, [disabled]); + + if (disabled && active !== -1) { + setActive(-1); + } + if (disabled && focusVisible !== -1) { + setFocusVisible(-1); + } + + const createHandleHiddenInputChange = + (otherHandlers: Record>) => (event: React.ChangeEvent) => { + otherHandlers.onChange?.(event); + + const index = Number(event.currentTarget.getAttribute('data-index')); + const value = values[index]; + const marksIndex = marksValues.indexOf(value); + + // @ts-ignore + let newValue = event.target.valueAsNumber; + + if (marks && step == null) { + newValue = newValue < value ? marksValues[marksIndex - 1] : marksValues[marksIndex + 1]; + } + + newValue = clamp(newValue, min, max); + + if (marks && step == null) { + const currentMarkIndex = marksValues.indexOf(values[index]); + + newValue = + newValue < values[index] + ? marksValues[currentMarkIndex - 1] + : marksValues[currentMarkIndex + 1]; + } + + 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); + setFocusVisible(index); + + if (handleChange) { + handleChange(event, newValue, index); + } + + if (onChangeCommitted) { + onChangeCommitted(event, newValue); + } + }; + + const previousIndex = React.useRef(); + let axis = orientation; + if (isRtl && orientation === 'horizontal') { + axis += '-reverse'; + } + + const getFingerNewValue = ({ + finger, + move = false, + values: values2, + }: { + finger: { x: number; y: number }; + move?: boolean; + values: number[]; + }) => { + const { current: slider } = sliderRef; + const { width, height, bottom, left } = slider!.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 = findClosest(marksValues, newValue); + newValue = marksValues[closestIndex!]; + } + + newValue = clamp(newValue, min, max); + let activeIndex = 0; + + if (range) { + if (!move) { + activeIndex = findClosest(values2, newValue)!; + } else { + activeIndex = previousIndex.current!; + } + + // Bound the new value to the thumb's neighbours. + if (disableSwap) { + newValue = clamp( + newValue, + values2[activeIndex - 1] || -Infinity, + values2[activeIndex + 1] || Infinity, + ); + } + + const previousValue = newValue; + newValue = setValueIndex({ + values: values2, + 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, + values, + }); + + focusThumb({ sliderRef, activeIndex, setActive }); + setValueState(newValue); + + if (!dragging && moveCount.current > INTENTIONAL_DRAG_COUNT_THRESHOLD) { + setDragging(true); + } + + if (handleChange) { + handleChange(nativeEvent, newValue, activeIndex); + } + }); + + const handleTouchEnd = useEventCallback((nativeEvent: TouchEvent | MouseEvent) => { + const finger = trackFinger(nativeEvent, touchId); + setDragging(false); + + if (!finger) { + return; + } + + const { newValue } = getFingerNewValue({ finger, values }); + + 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 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, values }); + focusThumb({ sliderRef, activeIndex, setActive }); + + setValueState(newValue); + + if (handleChange) { + handleChange(nativeEvent, newValue, activeIndex); + } + } + + moveCount.current = 0; + const doc = ownerDocument(sliderRef.current); + doc.addEventListener('touchmove', handleTouchMove); + doc.addEventListener('touchend', handleTouchEnd); + }); + + 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 () => { + // @ts-ignore + slider!.removeEventListener('touchstart', handleTouchStart, { + passive: doesSupportTouchActionNone(), + }); + + stopListening(); + }; + }, [stopListening, handleTouchStart]); + + React.useEffect(() => { + if (disabled) { + stopListening(); + } + }, [disabled, stopListening]); + + const createHandleMouseDown = + (otherHandlers: Record>) => + (event: React.MouseEvent) => { + otherHandlers.onMouseDown?.(event); + 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, values }); + focusThumb({ sliderRef, activeIndex, setActive }); + + setValueState(newValue); + + if (handleChange) { + handleChange(event, newValue, activeIndex); + } + } + + moveCount.current = 0; + const doc = ownerDocument(sliderRef.current); + doc.addEventListener('mousemove', handleTouchMove); + 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 = (otherHandlers?: Record>) => { + const ownEventHandlers = { + onMouseDown: createHandleMouseDown(otherHandlers || {}), + }; + + const mergedEventHandlers: Record> = { + ...otherHandlers, + ...ownEventHandlers, + }; + return { + ref: handleRef, + ...mergedEventHandlers, + }; + }; + + const createHandleMouseOver = + (otherHandlers: Record>) => + (event: React.MouseEvent) => { + otherHandlers.onMouseOver?.(event); + + const index = Number(event.currentTarget.getAttribute('data-index')); + setOpen(index); + }; + + const createHandleMouseLeave = + (otherHandlers: Record>) => + (event: React.MouseEvent) => { + otherHandlers.onMouseLeave?.(event); + + setOpen(-1); + }; + + const getThumbProps = (otherHandlers?: Record>) => { + const ownEventHandlers = { + onMouseOver: createHandleMouseOver(otherHandlers || {}), + onMouseLeave: createHandleMouseLeave(otherHandlers || {}), + }; + + const mergedEventHandlers: Record> = { + ...otherHandlers, + ...ownEventHandlers, + }; + return { + ...mergedEventHandlers, + }; + }; + + const getHiddenInputProps = (otherHandlers?: Record>) => { + const ownEventHandlers = { + onChange: createHandleHiddenInputChange(otherHandlers || {}), + onFocus: createHandleHiddenInputFocus(otherHandlers || {}), + onBlur: createHandleHidenInputBlur(otherHandlers || {}), + }; + + const mergedEventHandlers: Record> = { + ...otherHandlers, + ...ownEventHandlers, + }; + + return { + tabIndex, + 'aria-labelledby': ariaLabelledby, + 'aria-orientation': orientation, + 'aria-valuemax': scale(max), + 'aria-valuemin': scale(min), + name, + type: 'range', + min: props.min, + max: props.max, + step: props.step, + disabled, + ...mergedEventHandlers, + style: { + ...visuallyHidden, + direction: isRtl ? 'rtl' : 'ltr', + // So that VoiceOver's focus indicator matches the thumb's dimensions + width: '100%', + height: '100%', + }, + }; + }; + + return { + axis, + axisProps, + getRootProps, + getHiddenInputProps, + getThumbProps, + dragging, + marks, + values, + active, + focusVisible, + open, + range, + trackOffset, + trackLeap, + }; +} diff --git a/packages/mui-material/src/Badge/Badge.js b/packages/mui-material/src/Badge/Badge.js index 7b1b5faa573819..a7527a3c52609b 100644 --- a/packages/mui-material/src/Badge/Badge.js +++ b/packages/mui-material/src/Badge/Badge.js @@ -2,10 +2,11 @@ import * as React from 'react'; import PropTypes from 'prop-types'; import clsx from 'clsx'; import { usePreviousProps } from '@mui/utils'; -import { generateUtilityClasses, isHostComponent } from '@mui/base'; +import { generateUtilityClasses } from '@mui/base'; import BadgeUnstyled, { badgeUnstyledClasses, getBadgeUtilityClass } from '@mui/base/BadgeUnstyled'; import styled from '../styles/styled'; import useThemeProps from '../styles/useThemeProps'; +import shouldSpreadAdditionalProps from '../utils/shouldSpreadAdditionalProps'; import capitalize from '../utils/capitalize'; export const badgeClasses = { @@ -213,10 +214,6 @@ const BadgeBadge = styled('span', { }), })); -const shouldSpreadAdditionalProps = (Slot) => { - return !Slot || !isHostComponent(Slot); -}; - const Badge = React.forwardRef(function Badge(inProps, ref) { const props = useThemeProps({ props: inProps, name: 'MuiBadge' }); const { diff --git a/packages/mui-material/src/Slider/Slider.js b/packages/mui-material/src/Slider/Slider.js index 80a8ca1b0799e5..d4d0ba660d8b1a 100644 --- a/packages/mui-material/src/Slider/Slider.js +++ b/packages/mui-material/src/Slider/Slider.js @@ -2,7 +2,7 @@ import * as React from 'react'; import PropTypes from 'prop-types'; import clsx from 'clsx'; import { chainPropTypes } from '@mui/utils'; -import { generateUtilityClasses, isHostComponent } from '@mui/base'; +import { generateUtilityClasses } from '@mui/base'; import SliderUnstyled, { SliderValueLabelUnstyled, sliderUnstyledClasses, @@ -12,6 +12,7 @@ import { alpha, lighten, darken } from '@mui/system'; import useThemeProps from '../styles/useThemeProps'; import styled, { slotShouldForwardProp } from '../styles/styled'; import useTheme from '../styles/useTheme'; +import shouldSpreadAdditionalProps from '../utils/shouldSpreadAdditionalProps'; import capitalize from '../utils/capitalize'; export const sliderClasses = { @@ -26,7 +27,7 @@ export const sliderClasses = { ]), }; -export const SliderRoot = styled('span', { +const SliderRoot = styled('span', { name: 'MuiSlider', slot: 'Root', overridesResolver: (props, styles) => { @@ -109,7 +110,20 @@ export const SliderRoot = styled('span', { }, })); -export const SliderRail = styled('span', { +SliderRoot.propTypes /* remove-proptypes */ = { + // ----------------------------- Warning -------------------------------- + // | These PropTypes are generated from the TypeScript type definitions | + // | To update them edit the d.ts file and run "yarn proptypes" | + // ---------------------------------------------------------------------- + /** + * @ignore + */ + children: PropTypes.node, +}; + +export { SliderRoot }; + +const SliderRail = styled('span', { name: 'MuiSlider', slot: 'Rail', overridesResolver: (props, styles) => styles.rail, @@ -136,7 +150,20 @@ export const SliderRail = styled('span', { }), })); -export const SliderTrack = styled('span', { +SliderRail.propTypes /* remove-proptypes */ = { + // ----------------------------- Warning -------------------------------- + // | These PropTypes are generated from the TypeScript type definitions | + // | To update them edit the d.ts file and run "yarn proptypes" | + // ---------------------------------------------------------------------- + /** + * @ignore + */ + children: PropTypes.node, +}; + +export { SliderRail }; + +const SliderTrack = styled('span', { name: 'MuiSlider', slot: 'Track', overridesResolver: (props, styles) => styles.track, @@ -177,7 +204,20 @@ export const SliderTrack = styled('span', { }; }); -export const SliderThumb = styled('span', { +SliderTrack.propTypes /* remove-proptypes */ = { + // ----------------------------- Warning -------------------------------- + // | These PropTypes are generated from the TypeScript type definitions | + // | To update them edit the d.ts file and run "yarn proptypes" | + // ---------------------------------------------------------------------- + /** + * @ignore + */ + children: PropTypes.node, +}; + +export { SliderTrack }; + +const SliderThumb = styled('span', { name: 'MuiSlider', slot: 'Thumb', overridesResolver: (props, styles) => { @@ -252,7 +292,20 @@ export const SliderThumb = styled('span', { }, })); -export const SliderValueLabel = styled(SliderValueLabelUnstyled, { +SliderThumb.propTypes /* remove-proptypes */ = { + // ----------------------------- Warning -------------------------------- + // | These PropTypes are generated from the TypeScript type definitions | + // | To update them edit the d.ts file and run "yarn proptypes" | + // ---------------------------------------------------------------------- + /** + * @ignore + */ + children: PropTypes.node, +}; + +export { SliderThumb }; + +const SliderValueLabel = styled(SliderValueLabelUnstyled, { name: 'MuiSlider', slot: 'ValueLabel', overridesResolver: (props, styles) => styles.valueLabel, @@ -294,7 +347,20 @@ export const SliderValueLabel = styled(SliderValueLabelUnstyled, { }, })); -export const SliderMark = styled('span', { +SliderValueLabel.propTypes /* remove-proptypes */ = { + // ----------------------------- Warning -------------------------------- + // | These PropTypes are generated from the TypeScript type definitions | + // | To update them edit the d.ts file and run "yarn proptypes" | + // ---------------------------------------------------------------------- + /** + * @ignore + */ + children: PropTypes.node, +}; + +export { SliderValueLabel }; + +const SliderMark = styled('span', { name: 'MuiSlider', slot: 'Mark', shouldForwardProp: (prop) => slotShouldForwardProp(prop) && prop !== 'markActive', @@ -319,7 +385,20 @@ export const SliderMark = styled('span', { }), })); -export const SliderMarkLabel = styled('span', { +SliderMark.propTypes /* remove-proptypes */ = { + // ----------------------------- Warning -------------------------------- + // | These PropTypes are generated from the TypeScript type definitions | + // | To update them edit the d.ts file and run "yarn proptypes" | + // ---------------------------------------------------------------------- + /** + * @ignore + */ + children: PropTypes.node, +}; + +export { SliderMark }; + +const SliderMarkLabel = styled('span', { name: 'MuiSlider', slot: 'MarkLabel', shouldForwardProp: (prop) => slotShouldForwardProp(prop) && prop !== 'markLabelActive', @@ -348,7 +427,7 @@ export const SliderMarkLabel = styled('span', { }), })); -SliderRoot.propTypes = { +SliderMarkLabel.propTypes /* remove-proptypes */ = { // ----------------------------- Warning -------------------------------- // | These PropTypes are generated from the TypeScript type definitions | // | To update them edit the d.ts file and run "yarn proptypes" | @@ -357,44 +436,10 @@ SliderRoot.propTypes = { * @ignore */ children: PropTypes.node, - /** - * @ignore - */ - ownerState: PropTypes.shape({ - 'aria-label': PropTypes.string, - 'aria-labelledby': PropTypes.string, - 'aria-valuetext': PropTypes.string, - classes: PropTypes.object, - color: PropTypes.oneOf(['primary', 'secondary']), - defaultValue: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.number), PropTypes.number]), - disabled: PropTypes.bool, - getAriaLabel: PropTypes.func, - getAriaValueText: PropTypes.func, - isRtl: PropTypes.bool, - marks: PropTypes.oneOfType([ - PropTypes.arrayOf( - PropTypes.shape({ - label: PropTypes.node, - value: PropTypes.number.isRequired, - }), - ), - PropTypes.bool, - ]), - max: PropTypes.number, - min: PropTypes.number, - name: PropTypes.string, - onChange: PropTypes.func, - onChangeCommitted: PropTypes.func, - orientation: PropTypes.oneOf(['horizontal', 'vertical']), - scale: PropTypes.func, - step: PropTypes.number, - track: PropTypes.oneOf(['inverted', 'normal', false]), - value: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.number), PropTypes.number]), - valueLabelDisplay: PropTypes.oneOf(['auto', 'off', 'on']), - valueLabelFormat: PropTypes.oneOfType([PropTypes.func, PropTypes.string]), - }), }; +export { SliderMarkLabel }; + const extendUtilityClasses = (ownerState) => { const { color, size, classes = {} } = ownerState; @@ -417,17 +462,15 @@ const extendUtilityClasses = (ownerState) => { }; }; -const shouldSpreadOwnerState = (Component) => { - return !Component || !isHostComponent(Component); -}; - -const Slider = React.forwardRef(function Slider(sliderProps, ref) { - const props = useThemeProps({ props: sliderProps, name: 'MuiSlider' }); +const Slider = React.forwardRef(function Slider(inputProps, ref) { + const props = useThemeProps({ props: inputProps, name: 'MuiSlider' }); const theme = useTheme(); const isRtl = theme.direction === 'rtl'; const { + // eslint-disable-next-line react/prop-types + component = 'span', components = {}, componentsProps = {}, color = 'primary', @@ -457,25 +500,26 @@ const Slider = React.forwardRef(function Slider(sliderProps, ref) { ...componentsProps, root: { ...componentsProps.root, - ...(shouldSpreadOwnerState(components.Root) && { + ...(shouldSpreadAdditionalProps(components.Root) && { + as: component, ownerState: { ...componentsProps.root?.ownerState, color, size }, }), }, thumb: { ...componentsProps.thumb, - ...(shouldSpreadOwnerState(components.Thumb) && { + ...(shouldSpreadAdditionalProps(components.Thumb) && { ownerState: { ...componentsProps.thumb?.ownerState, color, size }, }), }, track: { ...componentsProps.track, - ...(shouldSpreadOwnerState(components.Track) && { + ...(shouldSpreadAdditionalProps(components.Track) && { ownerState: { ...componentsProps.track?.ownerState, color, size }, }), }, valueLabel: { ...componentsProps.valueLabel, - ...(shouldSpreadOwnerState(components.ValueLabel) && { + ...(shouldSpreadAdditionalProps(components.ValueLabel) && { ownerState: { ...componentsProps.valueLabel?.ownerState, color, size }, }), }, diff --git a/packages/mui-material/src/utils/shouldSpreadAdditionalProps.js b/packages/mui-material/src/utils/shouldSpreadAdditionalProps.js new file mode 100644 index 00000000000000..751ce4ee7aef79 --- /dev/null +++ b/packages/mui-material/src/utils/shouldSpreadAdditionalProps.js @@ -0,0 +1,7 @@ +import { isHostComponent } from '@mui/base'; + +const shouldSpreadAdditionalProps = (Slot) => { + return !Slot || !isHostComponent(Slot); +}; + +export default shouldSpreadAdditionalProps; diff --git a/test/utils/describeConformanceUnstyled.tsx b/test/utils/describeConformanceUnstyled.tsx index 53ebfd6b8ddb12..472bedbce53c74 100644 --- a/test/utils/describeConformanceUnstyled.tsx +++ b/test/utils/describeConformanceUnstyled.tsx @@ -42,7 +42,7 @@ interface WithClassName { interface WithCustomProp { fooBar: string; - tabIndex: number; + lang: string; } interface WithOwnerState { @@ -75,14 +75,14 @@ function testPropForwarding( it('forwards custom props to the root element if a component is provided', () => { const CustomRoot = React.forwardRef( - ({ fooBar, tabIndex }: WithCustomProp, ref: React.ForwardedRef) => { + ({ fooBar, lang }: WithCustomProp, ref: React.ForwardedRef) => { // @ts-ignore - return ; + return ; }, ); const otherProps = { - tabIndex: '0', + lang: 'fr', fooBar: randomStringValue(), }; @@ -90,13 +90,13 @@ function testPropForwarding( React.cloneElement(element, { components: { Root: CustomRoot }, ...otherProps }), ); - expect(container.firstChild).to.have.attribute('tabindex', otherProps.tabIndex.toString()); + expect(container.firstChild).to.have.attribute('lang', otherProps.lang); expect(container.firstChild).to.have.attribute('data-foobar', otherProps.fooBar); }); it('does forward standard props to the root element if an intrinsic element is provided', () => { const otherProps = { - tabIndex: '0', + lang: 'fr', 'data-foobar': randomStringValue(), }; @@ -104,7 +104,7 @@ function testPropForwarding( React.cloneElement(element, { components: { Root: Element }, ...otherProps }), ); - expect(container.firstChild).to.have.attribute('tabindex', otherProps.tabIndex); + expect(container.firstChild).to.have.attribute('lang', otherProps.lang); expect(container.firstChild).to.have.attribute('data-foobar', otherProps['data-foobar']); }); }