diff --git a/packages/configs/eslint-config/index.js b/packages/configs/eslint-config/index.js index ad4e72242..241b626e1 100644 --- a/packages/configs/eslint-config/index.js +++ b/packages/configs/eslint-config/index.js @@ -75,6 +75,7 @@ module.exports = { 'Pagination', 'PaginationItem', 'Radio', + 'Slider', 'SvgIcon', 'Switch', 'Table', @@ -195,6 +196,10 @@ module.exports = { group: ['@mui/material/Radio'], importNames: ['default'], }, + { + group: ['@mui/material/Slider'], + importNames: ['default'], + }, { group: ['@mui/material/SvgIcon'], importNames: ['default'], diff --git a/packages/react/src/components/AudioPlayer/AudioPlayer.tsx b/packages/react/src/components/AudioPlayer/AudioPlayer.tsx index 2aa140ce1..72ba6cc21 100644 --- a/packages/react/src/components/AudioPlayer/AudioPlayer.tsx +++ b/packages/react/src/components/AudioPlayer/AudioPlayer.tsx @@ -9,7 +9,6 @@ import { unstable_composeClasses as composeClasses } from '@mui/base'; import { styled, useThemeProps } from '@mui/material/styles'; import MenuList from '@mui/material/MenuList'; -import Slider, { sliderClasses } from '@mui/material/Slider'; import Tooltip, { tooltipClasses, TooltipProps } from '@mui/material/Tooltip'; import Typography from '@mui/material/Typography'; import TrapFocus from '@mui/material/Unstable_TrapFocus'; @@ -34,6 +33,7 @@ import { Button } from '../Button'; import { Divider, dividerClasses } from '../Divider'; import { ListItemIcon, ListItemText, listItemTextClasses } from '../ListItem'; import { MenuItem } from '../MenuItem'; +import { Slider, sliderClasses } from '../Slider'; import { Instance } from '@popperjs/core'; @@ -176,56 +176,53 @@ const AudioPlayerCurrentSlider = styled(Slider, { return [styles.currentSlider, isPlaying && styles.currentSliderPlaying, !isPlaying && styles.currentSliderPaused]; }, })<{ ownerState: AudioPlayerOwnerState }>(({ theme, ownerState }) => ({ - [`&.${sliderClasses.colorPrimary}`]: { - padding: '18px 0', - - [`& .${sliderClasses.thumb}`]: { - '&:hover': { - height: 8, - width: 8, - boxShadow: `0 0 0 10px ${theme.vars.palette.primary.A150}`, - }, - [`&.${sliderClasses.focusVisible}`]: { - opacity: 1, - height: 10, - width: 10, - boxShadow: `0 0 0 9px ${theme.vars.palette.primary.A400}`, - }, - [`&.${sliderClasses.active}`]: { - height: 10, - width: 10, - boxShadow: `0 0 0 9px ${theme.vars.palette.primary.A300}`, - }, - }, - ...(!ownerState.isPlaying && { - color: theme.vars.palette.monoA.A600, - [`& .${sliderClasses.thumb}`]: { - opacity: 0, - backdropFilter: 'blur(40px)', - - [`&.${sliderClasses.focusVisible}`]: { - opacity: 1, - height: 10, - width: 10, - boxShadow: `0 0 0 9px ${theme.vars.palette.monoA.A75}`, - }, - }, - }), - }, - [`& .${sliderClasses.thumb}`]: { - opacity: 0, - height: 8, - width: 8, - - '&::after': { - width: 12, - height: 30, - borderRadius: 0, - }, - }, - [`& .${sliderClasses.rail}`]: { - backgroundColor: theme.vars.palette.monoA.A100, - }, + // [`&.${sliderClasses.colorPrimary}`]: { + // padding: '18px 0', + // [`& .${sliderClasses.thumb}`]: { + // '&:hover': { + // height: 8, + // width: 8, + // boxShadow: `0 0 0 10px ${theme.vars.palette.primary.A150}`, + // }, + // [`&.${sliderClasses.focusVisible}`]: { + // opacity: 1, + // height: 10, + // width: 10, + // boxShadow: `0 0 0 9px ${theme.vars.palette.primary.A400}`, + // }, + // [`&.${sliderClasses.active}`]: { + // height: 10, + // width: 10, + // boxShadow: `0 0 0 9px ${theme.vars.palette.primary.A300}`, + // }, + // }, + // ...(!ownerState.isPlaying && { + // color: theme.vars.palette.monoA.A600, + // [`& .${sliderClasses.thumb}`]: { + // opacity: 0, + // backdropFilter: 'blur(40px)', + // [`&.${sliderClasses.focusVisible}`]: { + // opacity: 1, + // height: 10, + // width: 10, + // boxShadow: `0 0 0 9px ${theme.vars.palette.monoA.A75}`, + // }, + // }, + // }), + // }, + // [`& .${sliderClasses.thumb}`]: { + // opacity: 0, + // height: 8, + // width: 8, + // '&::after': { + // width: 12, + // height: 30, + // borderRadius: 0, + // }, + // }, + // [`& .${sliderClasses.rail}`]: { + // backgroundColor: theme.vars.palette.monoA.A100, + // }, })); const AudioPlayerTime = styled(Typography, { diff --git a/packages/react/src/components/Slider/Slider.api.mdx b/packages/react/src/components/Slider/Slider.api.mdx new file mode 100644 index 000000000..6f86474e2 --- /dev/null +++ b/packages/react/src/components/Slider/Slider.api.mdx @@ -0,0 +1,37 @@ +import { Meta } from '@storybook/blocks'; +import LinkTo from '@storybook/addon-links/react'; +import { TableInterface } from '~storybook/components/TableInterface'; + + + +# Dropzone API + +```js +import { Slider } from '@elonkit/react'; +``` + +## Component name + +The name `ESSlider` can be used when providing default props or style overrides in the theme. + +## Props + + + +
+ +## CSS + + + +
+ +## Demos + + diff --git a/packages/react/src/components/Slider/Slider.classes.ts b/packages/react/src/components/Slider/Slider.classes.ts new file mode 100644 index 000000000..936a72a4f --- /dev/null +++ b/packages/react/src/components/Slider/Slider.classes.ts @@ -0,0 +1,112 @@ +import { generateUtilityClass, generateUtilityClasses } from '@mui/material'; + +export interface SliderClasses { + /** Styles applied to the root element. */ + root: string; + /** Styles applied to the root element if `color="primary"`. */ + colorPrimary: string; + /** Styles applied to the root element if `color="secondary"`. */ + colorSecondary: string; + /** Styles applied to the root element if `color="error"`. */ + colorError: string; + /** Styles applied to the root element if `color="info"`. */ + colorInfo: string; + /** Styles applied to the root element if `color="success"`. */ + colorSuccess: string; + /** Styles applied to the root element if `color="warning"`. */ + colorWarning: string; + /** Styles applied to the root element if `marks` is provided with at least one label. */ + marked: string; + /** Styles applied to the root element if `orientation="horizontal"`. */ + horizontal: string; + /** Styles applied to the root element if `orientation="vertical"`. */ + vertical: string; + /** State class applied to the root and thumb element if `disabled={true}`. */ + disabled: string; + /** State class applied to the root if a thumb is being dragged. */ + dragging: string; + /** Styles applied to the rail element. */ + rail: string; + /** Styles applied to the track element. */ + track: string; + /** Styles applied to the root element if `track={false}`. */ + trackFalse: string; + /** Styles applied to the root element if `track="inverted"`. */ + trackInverted: string; + /** Styles applied to the thumb element. */ + thumb: string; + /** State class applied to the thumb element if it's active. */ + active: string; + /** State class applied to the thumb element if keyboard focused. */ + focusVisible: string; + /** Styles applied to the mark element. */ + mark: string; + /** Styles applied to the mark element if active (depending on the value). */ + markActive: string; + /** Styles applied to the mark label element. */ + markLabel: string; + /** Styles applied to the mark label element if active (depending on the value). */ + markLabelActive: string; + /** Styles applied to the thumb element if `color="primary"`. */ + thumbColorPrimary: string; + /** Styles applied to the thumb element if `color="secondary"`. */ + thumbColorSecondary: string; + /** Styles applied to the thumb element if `color="error"`. */ + thumbColorError: string; + /** Styles applied to the thumb element if `color="info"`. */ + thumbColorInfo: string; + /** Styles applied to the thumb element if `color="success"`. */ + thumbColorSuccess: string; + /** Styles applied to the thumb element if `color="warning"`. */ + thumbColorWarning: string; + /** Styles applied to the thumb label element. */ + valueLabel: string; + /** Styles applied to the thumb label element if it's open. */ + valueLabelOpen: string; + /** Styles applied to the thumb label's circle element. */ + valueLabelCircle: string; + /** Styles applied to the thumb label's label element. */ + valueLabelLabel: string; +} + +export type SliderClassKey = keyof SliderClasses; + +export function getSliderUtilityClass(slot: string): string { + return generateUtilityClass('ESSlider', slot); +} + +export const sliderClasses: SliderClasses = generateUtilityClasses('MuiSlider', [ + 'root', + 'active', + 'colorPrimary', + 'colorSecondary', + 'colorError', + 'colorInfo', + 'colorSuccess', + 'colorWarning', + 'disabled', + 'dragging', + 'focusVisible', + 'mark', + 'markActive', + 'marked', + 'markLabel', + 'markLabelActive', + 'rail', + 'thumb', + 'thumbColorPrimary', + 'thumbColorSecondary', + 'thumbColorError', + 'thumbColorSuccess', + 'thumbColorInfo', + 'thumbColorWarning', + 'track', + 'trackInverted', + 'trackFalse', + 'valueLabel', + 'valueLabelOpen', + 'valueLabelCircle', + 'valueLabelLabel', + 'vertical', + 'horizontal', +]); diff --git a/packages/react/src/components/Slider/Slider.stories.tsx b/packages/react/src/components/Slider/Slider.stories.tsx new file mode 100644 index 000000000..3b4f6be43 --- /dev/null +++ b/packages/react/src/components/Slider/Slider.stories.tsx @@ -0,0 +1,111 @@ +import { Meta, StoryObj } from '@storybook/react'; + +import { Slider } from '.'; + +const meta: Meta = { + tags: ['autodocs'], + component: Slider, + parameters: { + references: ['Slider'], + }, + argTypes: { + name: { + table: { + disable: true, + }, + }, + id: { + table: { + disable: true, + }, + }, + autoFocus: { + table: { + disable: true, + }, + }, + tabIndex: { + table: { + disable: true, + }, + }, + 'aria-label': { + table: { + disable: true, + }, + }, + 'aria-labelledby': { + table: { + disable: true, + }, + }, + 'aria-valuetext': { + table: { + disable: true, + }, + }, + getAriaLabel: { + table: { + disable: true, + }, + }, + getAriaValueText: { + table: { + disable: true, + }, + }, + step: { + control: { type: 'number' }, + }, + marks: { + control: { type: 'boolean' }, + }, + defaultValue: { + control: { type: 'number' }, + }, + value: { + control: { type: 'number' }, + }, + width: { + control: { type: 'number' }, + }, + }, + args: { + defaultValue: 50, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Demo: Story = { + render: (args) => { + return ( +
+ +
+ ); + }, +}; + +export const Range: Story = { + render: (args) => { + return ( +
+ +
+ ); + }, + argTypes: { + defaultValue: { + table: { + disable: true, + }, + }, + value: { + table: { + disable: true, + }, + }, + }, +}; diff --git a/packages/react/src/components/Slider/Slider.tsx b/packages/react/src/components/Slider/Slider.tsx new file mode 100644 index 000000000..3c8b353f7 --- /dev/null +++ b/packages/react/src/components/Slider/Slider.tsx @@ -0,0 +1,841 @@ +import React, { forwardRef } from 'react'; + +import { SliderOwnerState, SliderProps } from './Slider.types'; + +import clsx from 'clsx'; +import { getSliderUtilityClass, sliderClasses } from './Slider.classes'; + +import { unstable_composeClasses as composeClasses, useSlotProps } from '@mui/base'; +import { isHostComponent } from '@mui/base/utils'; + +import { styled, useThemeProps } from '@mui/material/styles'; +import { capitalize } from '@mui/material'; + +import { SliderValueLabel as BaseSliderValueLabel } from './SliderValueLabel'; +import { useSlider, valueToPercent } from './useSlider'; +import { shouldSpreadAdditionalProps } from './utils'; + +const useUtilityClasses = (ownerState: SliderOwnerState) => { + const { disabled, dragging, marked, orientation, track, classes, color } = ownerState; + + const slots = { + root: [ + 'root', + disabled && 'disabled', + dragging && 'dragging', + marked && 'marked', + orientation === 'vertical' && 'vertical', + track === 'inverted' && 'trackInverted', + track === false && 'trackFalse', + color && `color${capitalize(color)}`, + ], + rail: ['rail'], + track: ['track'], + mark: ['mark'], + markActive: ['markActive'], + markLabel: ['markLabel'], + markLabelActive: ['markLabelActive'], + valueLabel: ['valueLabel'], + thumb: ['thumb', disabled && 'disabled', color && `thumbColor${capitalize(color)}`], + active: ['active'], + disabled: ['disabled'], + focusVisible: ['focusVisible'], + }; + + return composeClasses(slots, getSliderUtilityClass, classes); +}; + +function Identity(x: number) { + return x; +} + +export const SliderRoot = styled('span', { + name: 'ESSlider', + slot: 'Root', + overridesResolver: (props, styles) => { + const { ownerState } = props; + + return [ + styles.root, + styles[`color${capitalize(ownerState.color)}`], + ownerState.marked && styles.marked, + ownerState.orientation === 'vertical' && styles.vertical, + ownerState.track === 'inverted' && styles.trackInverted, + ownerState.track === false && styles.trackFalse, + ]; + }, +})<{ ownerState: SliderOwnerState }>(({ theme, ownerState: { width } }) => ({ + borderRadius: '8px', + boxSizing: 'content-box', + display: 'inline-block', + position: 'relative', + cursor: 'pointer', + touchAction: 'none', + WebkitTapHighlightColor: 'transparent', + '@media print': { + colorAdjust: 'exact', + }, + + [`&.${sliderClasses.colorPrimary}`]: { + color: theme.vars.palette.primary[300], + [`& .${sliderClasses.thumb}`]: { + '&:hover': { + boxShadow: `0 0 0 8px ${theme.vars.palette.primary.A150}`, + }, + [`&.${sliderClasses.focusVisible}`]: { + boxShadow: `0 0 0 8px ${theme.vars.palette.primary.A400}`, + }, + [`&.${sliderClasses.active}`]: { + boxShadow: `0 0 0 6px ${theme.vars.palette.primary.A300}`, + }, + }, + }, + [`&.${sliderClasses.colorSecondary}`]: { + color: theme.vars.palette.secondary[300], + [`& .${sliderClasses.thumb}`]: { + '&:hover': { + boxShadow: `0 0 0 8px ${theme.vars.palette.secondary.A150}`, + }, + [`&.${sliderClasses.focusVisible}`]: { + boxShadow: `0 0 0 8px ${theme.vars.palette.secondary.A400}`, + }, + [`&.${sliderClasses.active}`]: { + boxShadow: `0 0 0 6px ${theme.vars.palette.secondary.A300}`, + }, + }, + }, + [`&.${sliderClasses.colorError}`]: { + color: theme.vars.palette.error[300], + [`& .${sliderClasses.thumb}`]: { + '&:hover': { + boxShadow: `0 0 0 8px ${theme.vars.palette.error.A150}`, + }, + [`&.${sliderClasses.focusVisible}`]: { + boxShadow: `0 0 0 8px ${theme.vars.palette.error.A400}`, + }, + [`&.${sliderClasses.active}`]: { + boxShadow: `0 0 0 6px ${theme.vars.palette.error.A300}`, + }, + }, + }, + [`&.${sliderClasses.colorInfo}`]: { + color: theme.vars.palette.info[300], + [`& .${sliderClasses.thumb}`]: { + '&:hover': { + boxShadow: `0 0 0 8px ${theme.vars.palette.info.A150}`, + }, + [`&.${sliderClasses.focusVisible}`]: { + boxShadow: `0 0 0 8px ${theme.vars.palette.info.A400}`, + }, + [`&.${sliderClasses.active}`]: { + boxShadow: `0 0 0 6px ${theme.vars.palette.info.A300}`, + }, + }, + }, + [`&.${sliderClasses.colorSuccess}`]: { + color: theme.vars.palette.success[300], + [`& .${sliderClasses.thumb}`]: { + '&:hover': { + boxShadow: `0 0 0 8px ${theme.vars.palette.success.A150}`, + }, + [`&.${sliderClasses.focusVisible}`]: { + boxShadow: `0 0 0 8px ${theme.vars.palette.success.A400}`, + }, + [`&.${sliderClasses.active}`]: { + boxShadow: `0 0 0 6px ${theme.vars.palette.success.A300}`, + }, + }, + }, + [`&.${sliderClasses.colorWarning}`]: { + color: theme.vars.palette.warning[300], + [`& .${sliderClasses.thumb}`]: { + '&:hover': { + boxShadow: `0 0 0 8px ${theme.vars.palette.warning.A150}`, + }, + [`&.${sliderClasses.focusVisible}`]: { + boxShadow: `0 0 0 8px ${theme.vars.palette.warning.A400}`, + }, + [`&.${sliderClasses.active}`]: { + boxShadow: `0 0 0 6px ${theme.vars.palette.warning.A300}`, + }, + }, + }, + [`&.${sliderClasses.disabled}`]: { + pointerEvents: 'none', + cursor: 'default', + color: theme.vars.palette.monoA.A200, + [`& .${sliderClasses.rail}`]: { + backgroundColor: theme.vars.palette.monoA.A200, + }, + [`& .${sliderClasses.track}`]: { + display: 'none', + }, + [`& .${sliderClasses.thumb}`]: { + width: '10px', + height: '10px', + backdropFilter: 'blur(100px)', + boxShadow: `0 0 0 2px ${theme.vars.palette.monoB[500]}`, + }, + }, + [`&.${sliderClasses.dragging}`]: { + [`& .${sliderClasses.thumb}, & .${sliderClasses.track}`]: { + transition: 'none', + }, + }, + variants: [ + { + props: { orientation: 'horizontal' }, + style: { + height: `${width ?? 4}px`, + width: '100%', + padding: '13px 0', + // The primary input mechanism of the device includes a pointing device of limited accuracy. + '@media (pointer: coarse)': { + // Reach 42px touch target, about ~8mm on screen. + padding: '20px 0', + }, + }, + }, + { + props: { orientation: 'horizontal', marked: true }, + style: { + marginBottom: 20, + }, + }, + { + props: { orientation: 'vertical' }, + style: { + height: '100%', + width: `${width ?? 4}px`, + padding: '0 13px', + // The primary input mechanism of the device includes a pointing device of limited accuracy. + '@media (pointer: coarse)': { + // Reach 42px touch target, about ~8mm on screen. + padding: '0 20px', + }, + }, + }, + { + props: { orientation: 'vertical', marked: true }, + style: { + marginRight: 44, + }, + }, + ], +})); + +export const SliderRail = styled('span', { + name: 'ESSlider', + slot: 'Rail', + overridesResolver: (props, styles) => styles.rail, +})(({ theme }) => { + return { + display: 'block', + position: 'absolute', + borderRadius: 'inherit', + backgroundColor: theme.vars.palette.monoA.A400, + variants: [ + { + props: { orientation: 'horizontal' }, + style: { + width: '100%', + height: 'inherit', + top: '50%', + transform: 'translateY(-50%)', + }, + }, + { + props: { orientation: 'vertical' }, + style: { + height: '100%', + width: 'inherit', + left: '50%', + transform: 'translateX(-50%)', + }, + }, + ], + }; +}); + +export const SliderTrack = styled('span', { + name: 'ESSlider', + slot: 'Track', + overridesResolver: (props, styles) => styles.track, +})(({ theme }) => { + return { + display: 'block', + position: 'absolute', + borderRadius: 'inherit', + backgroundColor: 'currentColor', + transition: theme.transitions.create(['left', 'width', 'bottom', 'height'], { + duration: theme.transitions.duration.shortest, + }), + transitionTimingFunction: 'linear', + + variants: [ + { + props: { orientation: 'horizontal' }, + style: { + height: 'inherit', + top: '50%', + transform: 'translateY(-50%)', + }, + }, + { + props: { orientation: 'vertical' }, + style: { + width: 'inherit', + left: '50%', + transform: 'translateX(-50%)', + }, + }, + { + props: { track: false }, + style: { + display: 'none', + }, + }, + ], + }; +}); + +export const SliderThumb = styled('span', { + name: 'ESSlider', + slot: 'Thumb', + overridesResolver: (props, styles) => { + const { ownerState } = props; + return [styles.thumb, styles[`thumbColor${capitalize(ownerState.color)}`]]; + }, +})(({ theme }) => ({ + position: 'absolute', + boxSizing: 'border-box', + borderRadius: '50%', + outline: 0, + backgroundColor: 'currentColor', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + // transition: theme.transitions.create(['box-shadow', 'left', 'bottom'], { + // duration: theme.transitions.duration.shortest, + // }), + + height: '12px', + transitionDuration: `${theme.transitions.duration.shortest}ms`, + transitionProperty: 'box-shadow, left, bottom, width, height, opacity', + transitionTimingFunction: 'linear', + width: '12px', + + '&::before': { + position: 'absolute', + content: '""', + borderRadius: 'inherit', + width: '100%', + height: '100%', + }, + '&::after': { + position: 'absolute', + content: '""', + borderRadius: '50%', + // 42px is the hit target + width: 42, + height: 42, + top: '50%', + left: '50%', + transform: 'translate(-50%, -50%)', + }, + [`&.${sliderClasses.disabled}`]: { + '&:hover': { + boxShadow: 'none', + }, + }, + [`&.${sliderClasses.focusVisible}`]: { + width: '14px', + height: '14px', + + '& .ESSlider-valueLabel': { + top: '-8px', + }, + }, + [`&.${sliderClasses.active}`]: { + width: '16px', + height: '16px', + + '& .ESSlider-valueLabel': { + top: '-7px', + }, + }, + + variants: [ + { + props: { orientation: 'horizontal' }, + style: { + top: '50%', + transform: 'translate(-50%, -50%)', + }, + }, + { + props: { orientation: 'vertical' }, + style: { + left: '50%', + transform: 'translate(-50%, 50%)', + }, + }, + ], +})); + +export const SliderValueLabel = styled(BaseSliderValueLabel, { + name: 'ESSlider', + slot: 'ValueLabel', + overridesResolver: (props, styles) => styles.valueLabel, +})(({ theme }) => ({ + ...theme.typography.caption, + backgroundColor: theme.vars.palette.monoA.A600, + borderRadius: '4px', + color: theme.vars.palette.monoB[500], + fontWeight: 400, + minWidth: '22px', + padding: '4px', + top: '-9px', + transitionDuration: `${theme.transitions.duration.shortest}ms`, + transitionProperty: 'transform, top', + transitionTimingFunction: 'linear', + + '&:before': { + background: 'none', + borderColor: `${theme.vars.palette.monoA.A600} transparent transparent transparent`, + borderStyle: 'solid', + borderWidth: '5px 5px 0 5px', + height: 0, + transform: 'translate(-50%, 100%)', + width: 0, + }, + + zIndex: 1, + whiteSpace: 'nowrap', + transition: theme.transitions.create(['transform'], { + duration: theme.transitions.duration.shortest, + }), + position: 'absolute', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + + variants: [ + { + props: { orientation: 'horizontal' }, + style: { + transform: 'translateY(-100%) scale(0)', + top: '-10px', + transformOrigin: 'bottom center', + '&::before': { + position: 'absolute', + content: '""', + width: 8, + height: 8, + transform: 'translate(-50%, 50%) rotate(45deg)', + backgroundColor: 'inherit', + bottom: 0, + left: '50%', + }, + [`&.${sliderClasses.valueLabelOpen}`]: { + transform: 'translateY(-100%) scale(1)', + }, + }, + }, + { + props: { orientation: 'vertical' }, + style: { + transform: 'translateY(-50%) scale(0)', + right: '30px', + top: '50%', + transformOrigin: 'right center', + '&::before': { + position: 'absolute', + content: '""', + width: 8, + height: 8, + transform: 'translate(-50%, -50%) rotate(45deg)', + backgroundColor: 'inherit', + right: -8, + top: '50%', + }, + [`&.${sliderClasses.valueLabelOpen}`]: { + transform: 'translateY(-50%) scale(1)', + }, + }, + }, + { + props: { orientation: 'vertical' }, + style: { + right: '20px', + }, + }, + ], +})); + +export const SliderMark = styled('span', { + name: 'ESSlider', + slot: 'Mark', + //shouldForwardProp: (prop) => slotShouldForwardProp(prop) && prop !== 'markActive', + overridesResolver: (props, styles) => { + const { markActive } = props; + + return [styles.mark, markActive && styles.markActive]; + }, +})(({ theme }) => ({ + position: 'absolute', + width: 2, + height: 2, + borderRadius: 1, + + backgroundColor: theme.vars.palette.monoA.A400, + + variants: [ + { + props: { orientation: 'horizontal' }, + style: { + top: '50%', + transform: 'translate(-1px, -50%)', + }, + }, + { + props: { orientation: 'vertical' }, + style: { + left: '50%', + transform: 'translate(-50%, 1px)', + }, + }, + { + props: { markActive: true }, + style: { + backgroundColor: theme.vars.palette.monoA.A400, + }, + }, + ], +})); + +export const SliderMarkLabel = styled('span', { + name: 'ESSlider', + slot: 'MarkLabel', + //shouldForwardProp: (prop) => slotShouldForwardProp(prop) && prop !== 'markLabelActive', + overridesResolver: (props, styles) => styles.markLabel, +})(({ theme }) => ({ + ...theme.typography.body2, + color: (theme.vars || theme).palette.text.secondary, + position: 'absolute', + whiteSpace: 'nowrap', + variants: [ + { + props: { orientation: 'horizontal' }, + style: { + top: 30, + transform: 'translateX(-50%)', + '@media (pointer: coarse)': { + top: 40, + }, + }, + }, + { + props: { orientation: 'vertical' }, + style: { + left: 36, + transform: 'translateY(50%)', + '@media (pointer: coarse)': { + left: 44, + }, + }, + }, + { + props: { markLabelActive: true }, + style: { + color: (theme.vars || theme).palette.text.primary, + }, + }, + ], +})); + +export const Slider = forwardRef(function Slider(inputProps, ref) { + const props = useThemeProps({ props: inputProps, name: 'ESSlider' }); + + const { + 'aria-label': ariaLabel, + 'aria-valuetext': ariaValuetext, + 'aria-labelledby': ariaLabelledby, + component = 'span', + + color = 'primary', + classes: classesProp, + className, + disabled = false, + getAriaLabel, + getAriaValueText, + marks: marksProp = false, + max = 100, + min = 0, + orientation = 'horizontal', + + width = 4, + scale = Identity, + slotProps, + slots, + track = 'normal', + valueLabelDisplay = 'off', + valueLabelFormat = Identity, + disableSwap, + step, + shiftStep, + value: valueProp, + defaultValue, + + onChange, + onChangeCommitted, + + ...other + } = props; + + const ownerState: SliderOwnerState = { + classes: classesProp, + disabled, + orientation, + track, + color, + width, + onChange, + onChangeCommitted, + }; + + const { + axisProps, + getRootProps, + getHiddenInputProps, + getThumbProps, + open, + active, + axis, + focusedThumbIndex, + range, + dragging, + marks, + values, + trackOffset, + trackLeap, + getThumbStyle, + } = useSlider({ + ...ownerState, + marks: marksProp, + rootRef: ref, + disableSwap, + step, + shiftStep, + defaultValue, + value: valueProp, + }); + + ownerState.marked = marks.length > 0 && marks.some((mark) => mark.label); + ownerState.dragging = dragging; + ownerState.focusedThumbIndex = focusedThumbIndex; + + const classes = useUtilityClasses(ownerState); + + const RootSlot = slots?.root ?? SliderRoot; + const RailSlot = slots?.rail ?? SliderRail; + const TrackSlot = slots?.track ?? SliderTrack; + const ThumbSlot = slots?.thumb ?? SliderThumb; + const ValueLabelSlot = slots?.valueLabel ?? SliderValueLabel; + const MarkSlot = slots?.mark ?? SliderMark; + const MarkLabelSlot = slots?.markLabel ?? SliderMarkLabel; + const InputSlot = slots?.input ?? 'input'; + + const rootSlotProps = slotProps?.root; + const railSlotProps = slotProps?.rail; + const trackSlotProps = slotProps?.track; + const thumbSlotProps = slotProps?.thumb; + const valueLabelSlotProps = slotProps?.valueLabel; + const markSlotProps = slotProps?.mark; + const markLabelSlotProps = slotProps?.markLabel; + const inputSlotProps = slotProps?.input; + + const rootProps = useSlotProps({ + elementType: RootSlot, + getSlotProps: getRootProps, + externalSlotProps: rootSlotProps, + externalForwardedProps: other, + additionalProps: { + ...(shouldSpreadAdditionalProps(RootSlot) && { + as: component, + }), + }, + ownerState: { + ...ownerState, + //...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, + //...trackSlotProps?.ownerState, + }, + className: classes.track, + }); + + const thumbProps = useSlotProps({ + elementType: ThumbSlot, + getSlotProps: getThumbProps, + externalSlotProps: thumbSlotProps, + ownerState: { + ...ownerState, + }, + className: classes.thumb, + }); + + const valueLabelProps = useSlotProps({ + elementType: ValueLabelSlot, + externalSlotProps: valueLabelSlotProps, + ownerState: { + ...ownerState, + // ...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, + ...other, + }); + + return ( + + + + {marks + .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 ( + + + {mark.label !== null && ( + + {mark.label} + + )} + + ); + })} + {values.map((value, index) => { + const percent = valueToPercent(value, min, max); + const style = axisProps[axis].offset(percent); + + return ( + + + + + + ); + })} + + ); +}); diff --git a/packages/react/src/components/Slider/Slider.types.ts b/packages/react/src/components/Slider/Slider.types.ts new file mode 100644 index 000000000..a14d5e241 --- /dev/null +++ b/packages/react/src/components/Slider/Slider.types.ts @@ -0,0 +1,272 @@ +/* eslint-disable @typescript-eslint/ban-types */ +import { Mark } from './useSlider.types'; + +import { SliderClasses } from './Slider.classes'; + +import { SlotComponentProps } from '@mui/base'; + +import { SxProps, Theme } from '@mui/material'; +import { OverrideProps } from '@mui/material/OverridableComponent'; + +import { SliderValueLabel as SliderValueLabelComponent } from './SliderValueLabel'; + +import { OverridableStringUnion } from '@mui/types'; + +export interface SliderPropsColorOverrides {} + +export interface SliderComponentsPropsOverrides {} + +export type SliderOwnerState = { + classes?: Partial; + disabled: boolean; + orientation: 'horizontal' | 'vertical'; + track: 'normal' | false | 'inverted'; + color: 'primary' | 'secondary' | 'error' | 'info' | 'success' | 'warning'; + width: number; + dragging?: boolean; + marked?: boolean; + focusedThumbIndex?: number; +}; + +export interface SliderOwnProps { + /** + * 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; + /** + * The color of the component. + * It supports both default and custom theme colors, which can be added as shown in the + * @default 'primary' + */ + color?: OverridableStringUnion< + 'primary' | 'secondary' | 'error' | 'info' | 'success' | 'warning', + SliderPropsColorOverrides + >; + + /** + * Override or extend the styles applied to the component. + */ + classes?: Partial; + /** + * @ignore + */ + className?: string; + /** + * 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; + /** + * 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. + * @param {any} x + * @returns {any} + * @default function Identity(x) { + * return x; + * } + */ + scale?: (value: number) => number; + /** + * The granularity with which the slider can step through values when using Page Up/Page Down or Shift + Arrow Up/Arrow Down. + * @default 10 + */ + shiftStep?: number; + + /** + * The props used for each slot inside the Slider. + * @default {} + */ + slotProps?: { + root?: SlotComponentProps<'span', SliderComponentsPropsOverrides, SliderOwnerState>; + track?: SlotComponentProps<'span', SliderComponentsPropsOverrides, SliderOwnerState>; + rail?: SlotComponentProps<'span', SliderComponentsPropsOverrides, SliderOwnerState>; + thumb?: SlotComponentProps<'span', SliderComponentsPropsOverrides, SliderOwnerState>; + mark?: SlotComponentProps<'span', SliderComponentsPropsOverrides, SliderOwnerState>; + markLabel?: SlotComponentProps<'span', SliderComponentsPropsOverrides, SliderOwnerState>; + valueLabel?: SlotComponentProps; + input?: SlotComponentProps<'input', SliderComponentsPropsOverrides, SliderOwnerState>; + }; + /** + * The components used for each slot inside the Slider. + * Either a string to use a HTML element or a component. + * @default {} + */ + slots?: { + 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 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; + /** + * The system prop that allows defining system overrides as well as additional CSS styles. + */ + sx?: SxProps; + /** + * 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 + * @param {any} x + * @returns {any} + * @default function Identity(x) { + * return x; + * } + */ + valueLabelFormat?: string | ((value: number, index: number) => React.ReactNode); + /** + * The width of the slider. + */ + width?: number; +} + +export interface SliderTypeMap { + props: AdditionalProps & SliderOwnProps; + defaultComponent: RootComponent; +} + +export interface SliderValueLabelProps extends React.HTMLAttributes { + children: React.ReactElement; + index: number; + open: boolean; + value: number; +} + +type SliderRootProps = NonNullable['root']; +type SliderMarkProps = NonNullable['mark']; +type SliderMarkLabelProps = NonNullable['markLabel']; +type SliderRailProps = NonNullable['rail']; +type SliderTrackProps = NonNullable['track']; +type SliderThumbProps = NonNullable['thumb']; + +export declare const SliderRoot: React.FC; +export declare const SliderMark: React.FC; +export declare const SliderMarkLabel: React.FC; +export declare const SliderRail: React.FC; +export declare const SliderTrack: React.FC; +export declare const SliderThumb: React.FC; +export declare const SliderValueLabel: React.FC; + +export type SliderProps< + RootComponent extends React.ElementType = SliderTypeMap['defaultComponent'], + AdditionalProps = {}, +> = OverrideProps, RootComponent> & { + component?: React.ElementType; +}; diff --git a/packages/react/src/components/Slider/SliderValueLabel.tsx b/packages/react/src/components/Slider/SliderValueLabel.tsx new file mode 100644 index 000000000..59674cc0e --- /dev/null +++ b/packages/react/src/components/Slider/SliderValueLabel.tsx @@ -0,0 +1,52 @@ +import { cloneElement } from 'react'; + +import { SliderValueLabelProps } from './SliderValueLabel.types'; + +import clsx from 'clsx'; +import { sliderClasses } from './Slider.classes'; + +import PropTypes from 'prop-types'; + +const useValueLabelClasses = (props: SliderValueLabelProps) => { + const { open } = props; + + const utilityClasses = { + offset: clsx({ + [sliderClasses.valueLabelOpen]: open, + }), + circle: sliderClasses.valueLabelCircle, + label: sliderClasses.valueLabelLabel, + }; + + return utilityClasses; +}; + +export function SliderValueLabel(props: SliderValueLabelProps) { + const { children, className, value } = props; + const classes = useValueLabelClasses(props); + + if (!children) { + return null; + } + + return cloneElement( + children, + { + className: clsx(children.props.className), + }, + <> + {children.props.children} + + + {value} + + + + ); +} + +SliderValueLabel.propTypes = { + children: PropTypes.element.isRequired, + className: PropTypes.string, + value: PropTypes.node, +} as any; diff --git a/packages/react/src/components/Slider/SliderValueLabel.types.ts b/packages/react/src/components/Slider/SliderValueLabel.types.ts new file mode 100644 index 000000000..251c91edc --- /dev/null +++ b/packages/react/src/components/Slider/SliderValueLabel.types.ts @@ -0,0 +1,23 @@ +export interface SliderValueLabelProps { + children?: React.ReactElement; + className?: string; + style?: React.CSSProperties; + /** + * If `true`, the value label is visible. + */ + open: boolean; + /** + * The value of the slider. + * For ranged sliders, provide an array with two values. + */ + value: 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'; +} diff --git a/packages/react/src/components/Slider/index.ts b/packages/react/src/components/Slider/index.ts new file mode 100644 index 000000000..acb29dfe0 --- /dev/null +++ b/packages/react/src/components/Slider/index.ts @@ -0,0 +1,3 @@ +export { Slider } from './Slider'; +export { SliderClasses, sliderClasses, SliderClassKey } from './Slider.classes'; +export { SliderProps } from './Slider.types'; diff --git a/packages/react/src/components/Slider/useSlider.ts b/packages/react/src/components/Slider/useSlider.ts new file mode 100644 index 000000000..6cf0793db --- /dev/null +++ b/packages/react/src/components/Slider/useSlider.ts @@ -0,0 +1,739 @@ +import * as React from 'react'; + +import { + Mark, + UseSliderHiddenInputProps, + UseSliderParameters, + UseSliderReturnValue, + UseSliderRootSlotProps, + UseSliderThumbSlotProps, +} from './useSlider.types'; + +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 { areArraysEqual, EventHandlers, extractEventHandlers } from './utils'; + +const INTENTIONAL_DRAG_COUNT_THRESHOLD = 2; + +function asc(a: number, b: number) { + return a - b; +} + +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; +} + +function trackFinger(event: TouchEvent | MouseEvent | React.MouseEvent, touchId: React.RefObject) { + // The event is TouchEvent + if (touchId.current !== undefined && (event as TouchEvent).changedTouches) { + const touchEvent = event as TouchEvent; + + 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 as MouseEvent).clientX, + y: (event as MouseEvent).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 mDecimalPart = parts[0].split('.')[1]; + return (mDecimalPart ? mDecimalPart.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 + ) { + sliderRef.current?.querySelector(`[type="range"][data-index="${activeIndex}"]`).focus(); + } + + if (setActive) { + setActive(activeIndex); + } +} + +function areValuesEqual(newValue: number | ReadonlyArray, oldValue: number | ReadonlyArray): boolean { + 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: 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; +} + +/** + * + * Demos: + * + * - [Slider](https://next.mui.com/base-ui/react-slider/#hook) + * + * API: + * + * - [useSlider API](https://next.mui.com/base-ui/react-slider/hooks-api/#use-slider) + */ +export function useSlider(parameters: UseSliderParameters): UseSliderReturnValue { + const { + 'aria-labelledby': ariaLabelledby, + defaultValue, + disabled = false, + disableSwap = false, + isRtl = false, + 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(); + + 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) => { + const nativeEvent = (event as React.SyntheticEvent).nativeEvent || event; + // @ts-expect-error: 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 Mark[]).map((mark: Mark) => mark.value); + + const { + isFocusVisibleRef, + onBlur: handleBlurVisible, + onFocus: handleFocusVisible, + ref: focusVisibleRef, + } = useIsFocusVisible(); + const [focusedThumbIndex, setFocusedThumbIndex] = React.useState(-1); + + const sliderRef = React.useRef(); + const handleFocusRef = useForkRef(focusVisibleRef, sliderRef); + const handleRef = useForkRef(ref, handleFocusRef); + + const createHandleHiddenInputFocus = (otherHandlers: EventHandlers) => (event: React.FocusEvent) => { + const index = Number(event.currentTarget.getAttribute('data-index')); + handleFocusVisible(event); + + if (isFocusVisibleRef.current === true) { + setFocusedThumbIndex(index); + } + + setOpen(index); + otherHandlers?.onFocus?.(event); + }; + + const createHandleHiddenInputBlur = (otherHandlers: EventHandlers) => (event: React.FocusEvent) => { + handleBlurVisible(event); + + if (isFocusVisibleRef.current === false) { + setFocusedThumbIndex(-1); + } + + setOpen(-1); + otherHandlers?.onBlur?.(event); + }; + + const changeValue = (event: React.KeyboardEvent | React.ChangeEvent, valueInput: number) => { + const index = Number(event.currentTarget.getAttribute('data-index')); + const value = values[index]; + const marksIndex = marksValues.indexOf(value); + let newValue: number | number[] = 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) { + 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); + setFocusedThumbIndex(index); + + if (handleChange && !areValuesEqual(newValue, valueDerived)) { + handleChange(event, newValue, index); + } + + if (onChangeCommitted) { + onChangeCommitted(event, newValue); + } + }; + + const createHandleHiddenInputKeyDown = (otherHandlers: EventHandlers) => (event: React.KeyboardEvent) => { + // 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 = 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?.onKeyDown?.(event); + }; + + useEnhancedEffect(() => { + if (disabled && sliderRef.current!.contains(document.activeElement)) { + // @ts-expect-error: This is necessary because Firefox and Safari will keep focus on a disabled element + document.activeElement?.blur(); + } + }, [disabled]); + + if (disabled && active !== -1) { + setActive(-1); + } + + if (disabled && focusedThumbIndex !== -1) { + setFocusedThumbIndex(-1); + } + + const createHandleHiddenInputChange = (otherHandlers: EventHandlers) => (event: React.ChangeEvent) => { + otherHandlers.onChange?.(event); + // @ts-expect-error: valueAsNumber + changeValue(event, event.target.valueAsNumber); + }; + + const previousIndex = React.useRef(); + let axis = orientation; + + if (isRtl && orientation === 'horizontal') { + axis += '-reverse'; + } + + const getFingerNewValue = ({ finger, move = false }: { finger: { x: number; y: number }; move?: boolean }) => { + 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 = previousIndex.current!; + } else { + activeIndex = findClosest(values, newValue)!; + } + + 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 handleTouchEnd = useEventCallback((nativeEvent: TouchEvent | MouseEvent) => { + const finger = trackFinger(nativeEvent, touchId); + setDragging(false); + + if (!finger) { + return; + } + + const { newValue } = getFingerNewValue({ finger, move: true }); + + 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 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-expect-error: buttons doesn't not exists on touch event + if (nativeEvent.type === 'mousemove' && nativeEvent.buttons === 0) { + handleTouchEnd(nativeEvent); + return; + } + + const { newValue, activeIndex } = getFingerNewValue({ + finger, + move: true, + }); + + 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 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 }); + 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: EventHandlers) => (event: React.MouseEvent) => { + otherHandlers.onMouseDown?.(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 }); + 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 = = any>( + externalProps: ExternalProps = {} as ExternalProps + ): UseSliderRootSlotProps => { + const externalHandlers = extractEventHandlers(externalProps); + + const ownEventHandlers = { + onMouseDown: createHandleMouseDown(externalHandlers || {}), + }; + + const mergedEventHandlers = { + ...externalHandlers, + ...ownEventHandlers, + }; + + return { + ...externalProps, + ref: handleRef, + ...mergedEventHandlers, + }; + }; + + const createHandleMouseOver = + (otherHandlers: EventHandlers) => (event: React.MouseEvent) => { + otherHandlers.onMouseOver?.(event); + + const index = Number(event.currentTarget.getAttribute('data-index')); + setOpen(index); + }; + + const createHandleMouseLeave = + (otherHandlers: EventHandlers) => (event: React.MouseEvent) => { + otherHandlers.onMouseLeave?.(event); + + setOpen(-1); + }; + + const getThumbProps = = any>( + externalProps: ExternalProps = {} as ExternalProps + ): UseSliderThumbSlotProps => { + const externalHandlers = extractEventHandlers(externalProps); + + const ownEventHandlers = { + onMouseOver: createHandleMouseOver(externalHandlers || {}), + onMouseLeave: createHandleMouseLeave(externalHandlers || {}), + }; + + return { + ...externalProps, + ...externalHandlers, + ...ownEventHandlers, + }; + }; + + const getThumbStyle = (index: number) => { + return { + // So the non active thumb doesn't show its label on hover. + pointerEvents: active !== -1 && active !== index ? 'none' : undefined, + }; + }; + + const getHiddenInputProps = = any>( + externalProps: ExternalProps = {} as ExternalProps + ): UseSliderHiddenInputProps => { + const externalHandlers = extractEventHandlers(externalProps); + + const ownEventHandlers = { + onChange: createHandleHiddenInputChange(externalHandlers || {}), + onFocus: createHandleHiddenInputFocus(externalHandlers || {}), + onBlur: createHandleHiddenInputBlur(externalHandlers || {}), + onKeyDown: createHandleHiddenInputKeyDown(externalHandlers || {}), + }; + + const mergedEventHandlers = { + ...externalHandlers, + ...ownEventHandlers, + }; + + return { + 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 ?? undefined, + disabled, + ...externalProps, + ...mergedEventHandlers, + style: { + ...visuallyHidden, + direction: isRtl ? 'rtl' : 'ltr', + // So that VoiceOver's focus indicator matches the thumb's dimensions + width: '100%', + height: '100%', + }, + }; + }; + + return { + active, + axis: axis as keyof typeof axisProps, + axisProps, + dragging, + focusedThumbIndex, + getHiddenInputProps, + getRootProps, + getThumbProps, + marks: marks as Mark[], + open, + range, + rootRef: handleRef, + trackLeap, + trackOffset, + values, + getThumbStyle, + }; +} diff --git a/packages/react/src/components/Slider/useSlider.types.ts b/packages/react/src/components/Slider/useSlider.types.ts new file mode 100644 index 000000000..5af03db2d --- /dev/null +++ b/packages/react/src/components/Slider/useSlider.types.ts @@ -0,0 +1,248 @@ +export interface Mark { + value: number; + label?: React.ReactNode; +} + +export interface UseSliderParameters { + /** + * The id of the element containing a label for the slider. + */ + 'aria-labelledby'?: string; + /** + * The default value. Use when the component is not controlled. + */ + defaultValue?: number | ReadonlyArray; + /** + * 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; + /** + * If `true` the Slider will be rendered right-to-left (with the lowest value on the right-hand side). + * @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 | ReadonlyArray; + /** + * 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'; + /** + * The ref attached to the root of the Slider. + */ + rootRef?: React.Ref; + /** + * A transformation function, to change the scale of the slider. + * @param {any} x + * @returns {any} + * @default function Identity(x) { + * return x; + * } + */ + scale?: (value: number) => number; + /** + * The granularity with which the slider can step through values when using Page Up/Page Down or Shift + Arrow Up/Arrow Down. + * @default 10 + */ + shiftStep?: 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 value of the slider. + * For ranged sliders, provide an array with two values. + */ + value?: number | ReadonlyArray; +} + +export type UseSliderRootSlotOwnProps = { + onMouseDown: React.MouseEventHandler; + ref: React.RefCallback | null; +}; + +export type UseSliderRootSlotProps = Omit & + UseSliderRootSlotOwnProps; + +export type UseSliderThumbSlotOwnProps = { + onMouseLeave: React.MouseEventHandler; + onMouseOver: React.MouseEventHandler; +}; + +export type UseSliderThumbSlotProps = Omit & + UseSliderThumbSlotOwnProps; + +export type UseSliderHiddenInputOwnProps = { + 'aria-labelledby'?: string; + 'aria-orientation'?: React.AriaAttributes['aria-orientation']; + 'aria-valuemax'?: React.AriaAttributes['aria-valuemax']; + 'aria-valuemin'?: React.AriaAttributes['aria-valuemin']; + disabled: boolean; + name?: string; + onBlur: React.FocusEventHandler; + onChange: React.ChangeEventHandler; + onFocus: React.FocusEventHandler; + step?: number | 'any'; + style: React.CSSProperties; + tabIndex?: number; + type?: React.InputHTMLAttributes['type']; +}; + +export type UseSliderHiddenInputProps = Omit & + UseSliderHiddenInputOwnProps; + +export type Axis = 'horizontal' | 'vertical' | 'horizontal-reverse'; + +export interface AxisProps { + offset: ( + percent: number + ) => T extends 'horizontal' + ? { left: string } + : T extends 'vertical' + ? { bottom: string } + : T extends 'horizontal-reverse' + ? { right: string } + : never; + leap: ( + percent: number + ) => T extends 'horizontal' | 'horizontal-reverse' + ? { width: string } + : T extends 'vertical' + ? { height: string } + : never; +} + +export interface UseSliderReturnValue { + /** + * The active index of the slider. + */ + active: number; + /** + * The orientation of the slider. + */ + axis: Axis; + /** + * Returns the `offset` and `leap` methods to calculate the positioning styles based on the slider axis. + */ + axisProps: { [key in Axis]: AxisProps }; + /** + * If `true`, the slider is being dragged. + */ + dragging: boolean; + /** + * The index of the thumb which is focused on the slider. + */ + focusedThumbIndex: number; + /** + * Resolver for the hidden input slot's props. + * @param externalProps props for the hidden input slot + * @returns props that should be spread on the hidden input slot + */ + getHiddenInputProps: = any>( + externalProps?: ExternalProps + ) => UseSliderHiddenInputProps; + /** + * Resolver for the root slot's props. + * @param externalProps props for the root slot + * @returns props that should be spread on the root slot + */ + getRootProps: = any>( + externalProps?: ExternalProps + ) => UseSliderRootSlotProps; + /** + * Resolver for the thumb slot's props. + * @param externalProps props for the thumb slot + * @returns props that should be spread on the thumb slot + */ + getThumbProps: = any>( + externalProps?: ExternalProps + ) => UseSliderThumbSlotProps; + /** + * Resolver for the thumb slot's style prop. + * @param index of the currently moved thumb + * @returns props that should be spread on the style prop of thumb slot + */ + getThumbStyle: (index: number) => object; + /** + * The marks of the slider. Marks indicate predetermined values to which the user can move the slider. + */ + marks: Mark[]; + /** + * The thumb index for the current value when in hover state. + */ + open: number; + /** + * If `true`, the slider is a range slider when the `value` prop passed is an array. + */ + range: boolean; + /** + * Ref to the root slot's DOM node. + */ + rootRef: React.RefCallback | null; + /** + * The track leap for the current value of the slider. + */ + trackLeap: number; + /** + * The track offset for the current value of the slider. + */ + trackOffset: number; + /** + * The possible values of the slider. + */ + values: number[]; +} diff --git a/packages/react/src/components/Slider/utils.ts b/packages/react/src/components/Slider/utils.ts new file mode 100644 index 000000000..2f8611356 --- /dev/null +++ b/packages/react/src/components/Slider/utils.ts @@ -0,0 +1,40 @@ +import { isHostComponent } from '@mui/base/utils'; + +export const shouldSpreadAdditionalProps = (Slot: React.ElementType) => { + return !Slot || !isHostComponent(Slot); +}; + +export function slotShouldForwardProp(prop: string) { + return prop !== 'ownerState' && prop !== 'theme' && prop !== 'sx' && prop !== 'as'; +} + +type ItemComparer = (a: Item, b: Item) => boolean; + +export function areArraysEqual( + array1: ReadonlyArray, + array2: ReadonlyArray, + itemComparer: ItemComparer = (a, b) => a === b +) { + return array1.length === array2.length && array1.every((value, index) => itemComparer(value, array2[index])); +} + +export type EventHandlers = Record>; + +export function extractEventHandlers( + object: Record | undefined, + excludeKeys: string[] = [] +): EventHandlers { + if (object === undefined) { + return {}; + } + + const result: EventHandlers = {}; + + Object.keys(object) + .filter((prop) => prop.match(/^on[A-Z]/) && typeof object[prop] === 'function' && !excludeKeys.includes(prop)) + .forEach((prop) => { + result[prop] = object[prop]; + }); + + return result; +} diff --git a/packages/react/src/components/index.ts b/packages/react/src/components/index.ts index 21e6de9e8..336f3f0fc 100644 --- a/packages/react/src/components/index.ts +++ b/packages/react/src/components/index.ts @@ -46,6 +46,7 @@ export * from './RibbonBadge'; export * from './SFS'; export * from './Sidebar'; export * from './Sidenav'; +export * from './Slider'; export * from './SortingMenu'; export * from './Spinner'; export * from './Spinner'; diff --git a/packages/react/src/overrides.d.ts b/packages/react/src/overrides.d.ts index 4ded9188a..901ad941c 100644 --- a/packages/react/src/overrides.d.ts +++ b/packages/react/src/overrides.d.ts @@ -245,6 +245,7 @@ import { SwiperPaginationProps, SwiperProps, } from './components/Swiper'; +import { SliderClassKey, SLiderProps } from './components/Slider'; import { SwitchClassKey, SwitchProps } from './components/Switch'; import { SwitchBaseClassKey, SwitchBaseProps } from './components/SwitchBase'; import { TabBarClassKey, TabBarItemClassKey, TabBarItemProps, TabBarProps } from './components/TabBar'; @@ -445,6 +446,7 @@ declare module '@mui/material/styles/props' { ESSpinnerRing: SpinnerRingProps; ESSortingMenu: SortingMenuProps; ESSvgIcon: SvgIconProps; + ESSlider: SliderProps; ESSwiper: SwiperProps; ESSwitch: SwitchProps; ESSwitchBase: SwitchBaseProps; @@ -580,6 +582,7 @@ declare module '@mui/material/styles/overrides' { ESSpinnerRing: SpinnerRingClassKey; ESSortingMenu: SortingMenuClassKey; ESSvgIcon: SvgIconClassKey; + ESSlider: SliderClassKey; ESSwiper: SwiperClassKey; ESSwitch: SwitchClassKey; ESSwitchBase: SwitchBaseClassKey; @@ -832,6 +835,10 @@ declare module '@mui/material/styles/components' { defaultProps?: ComponentsProps['ESGalleryThumbnailsItem']; styleOverrides?: ComponentsOverrides['ESGalleryThumbnailsItem']; }; + ESSlider?: { + defaultProps?: ComponentsProps['ESSlider']; + styleOverrides?: ComponentsOverrides['ESSlider']; + }; ESSwiper?: { defaultProps?: ComponentsProps['ESSwiper']; styleOverrides?: ComponentsOverrides['ESSwiper']; diff --git a/packages/react/src/theming/components/slider/slider.stories.tsx b/packages/react/src/theming/components/slider/slider.stories.tsx index 1b461fafd..068e82a95 100644 --- a/packages/react/src/theming/components/slider/slider.stories.tsx +++ b/packages/react/src/theming/components/slider/slider.stories.tsx @@ -2,6 +2,7 @@ import { useState } from 'react'; import { Meta, StoryObj } from '@storybook/react'; +// eslint-disable-next-line no-restricted-imports import Slider, { SliderProps } from '@mui/material/Slider'; const MARKS = [{ value: 0 }, { value: 10 }, { value: 20 }, { value: 30 }, { value: 40 }, { value: 50 }];