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