(null);
+
+ return (
+
+
setVal1(newValue as number)}
+ onValueCommitted={(newValue) => setVal2(newValue as number)}
+ >
+
+
+
+
+
+
+
+
+
+
onValueChange value: {val1}
+
onValueCommitted value: {val2}
+
+
+ );
+}
diff --git a/docs/pages/experiments/slider-marks.tsx b/docs/pages/experiments/slider-marks.tsx
new file mode 100644
index 0000000000..05a8204146
--- /dev/null
+++ b/docs/pages/experiments/slider-marks.tsx
@@ -0,0 +1,257 @@
+import * as React from 'react';
+import * as Slider from '@base_ui/react/Slider';
+import { useSliderContext } from '@base_ui/react/Slider';
+
+const STOPS = [
+ {
+ value: 0,
+ label: '0°C',
+ },
+ {
+ value: 25,
+ label: '25°C',
+ },
+ {
+ value: 50,
+ label: '50°C',
+ },
+ {
+ value: 75,
+ label: '75°C',
+ },
+ {
+ value: 100,
+ label: '100°C',
+ },
+];
+
+function getSliderThumbAriaValueText(value: number) {
+ return `${value}°C`;
+}
+
+// for "inverted track", the track/rail can be styled with CSS but a prop is needed to flip the "mark active" state
+function MarkWithLabel(props: { index: number; value: number; label: string; inverted?: boolean }) {
+ const { index, value, label, inverted = false } = props;
+ const { direction, values } = useSliderContext();
+ const isRtl = direction === 'rtl';
+ const isFilled = inverted ? value >= values[0] : values[0] >= value;
+ return (
+
+
+
+ {label}
+
+
+ );
+}
+
+export default function App() {
+ return (
+
+
+ LTR
+
+
+
+ {STOPS.map((mark, index) => (
+
+ ))}
+
+
+
+
+
+
+
+ RTL
+
+
+
+ {STOPS.map((mark, index) => (
+
+ ))}
+
+
+
+
+
+
+
+ );
+}
+
+function BrandingStyles() {
+ return (
+
+ );
+}
diff --git a/docs/pages/experiments/slider-template.tsx b/docs/pages/experiments/slider-template.tsx
new file mode 100644
index 0000000000..ef38d7c628
--- /dev/null
+++ b/docs/pages/experiments/slider-template.tsx
@@ -0,0 +1,18 @@
+import * as React from 'react';
+import * as Slider from '@base_ui/react/Slider';
+import { Styles } from './slider';
+
+export default function App() {
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/docs/pages/experiments/slider-tooltip.tsx b/docs/pages/experiments/slider-tooltip.tsx
new file mode 100644
index 0000000000..a05a681174
--- /dev/null
+++ b/docs/pages/experiments/slider-tooltip.tsx
@@ -0,0 +1,229 @@
+import * as React from 'react';
+import { useTheme } from '@mui/system';
+import * as Slider from '@base_ui/react/Slider';
+import * as Tooltip from '@base_ui/react/Tooltip';
+
+function useIsDarkMode() {
+ const theme = useTheme();
+ return theme.palette.mode === 'dark';
+}
+
+// making a Material/Joy style slider with Slider + Tooltip
+export default function App() {
+ const [valueLabelOpen, setValueLabelOpen] = React.useState(false);
+
+ const handleGlobalPointerUp = () => {
+ if (valueLabelOpen) {
+ setValueLabelOpen(false);
+ }
+ };
+
+ return (
+
+
+
+
+
+ {Array.from(Array(10), (_, x) => x).map((v) => {
+ return ;
+ })}
+
+ {
+ if (!valueLabelOpen) {
+ setValueLabelOpen(true);
+ }
+ }}
+ onBlur={() => {
+ if (valueLabelOpen) {
+ setValueLabelOpen(false);
+ }
+ }}
+ onPointerOver={() => {
+ if (!valueLabelOpen) {
+ setValueLabelOpen(true);
+ }
+ }}
+ onPointerLeave={(event) => {
+ if (event.buttons !== 1) {
+ setValueLabelOpen(false);
+ } else {
+ document.addEventListener('pointerup', handleGlobalPointerUp, { once: true });
+ }
+ }}
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+const SliderMark = React.forwardRef(function SliderMark(props: any, ref: React.ForwardedRef) {
+ const { index, style, ...otherProps } = props;
+ const { percentageValues } = Slider.useSliderContext();
+ const isFilled = percentageValues[0] >= index * 10;
+ return (
+
+ );
+});
+
+function Styles() {
+ // Replace this with your app logic for determining dark mode
+ const isDarkMode = useIsDarkMode();
+ return (
+
+ );
+}
diff --git a/docs/pages/experiments/slider.tsx b/docs/pages/experiments/slider.tsx
new file mode 100644
index 0000000000..116cd5cef2
--- /dev/null
+++ b/docs/pages/experiments/slider.tsx
@@ -0,0 +1,436 @@
+import * as React from 'react';
+import { useTheme } from '@mui/system';
+import * as Slider from '@base_ui/react/Slider';
+import { useSliderContext } from '@base_ui/react/Slider';
+
+export default function App() {
+ const [val1, setVal1] = React.useState(50);
+ const [val2, setVal2] = React.useState([40, 60]);
+ const [val3, setVal3] = React.useState([20, 40, 60, 80]);
+ return (
+
+
Uncontrolled
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Controlled
+
+ {
+ setVal1(newValue as number);
+ }}
+ >
+
+
+
+
+
+
+
+
+
+ {
+ setVal2(newValue as number[]);
+ }}
+ >
+
+
+
+
+
+
+
+
+
+
+ {
+ setVal3(newValue as number[]);
+ }}
+ >
+
+
+
+ {val3.map((_val, idx) => (
+
+ ))}
+
+
+
+
+
+ With custom labels
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Volume Range
+
+
+
+
+
+
+
+
+
+
+
+
+ Vertical
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ RTL
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Price Range
+
+
+
+
+ );
+}
+
+function Label(props: any) {
+ const { id: idProp, ...otherProps } = props;
+ const defaultId = React.useId();
+ const labelId = idProp ?? defaultId;
+
+ const { subitems } = useSliderContext();
+
+ const htmlFor = Array.from(subitems.values())
+ .reduce((acc, item) => {
+ return `${acc} ${item.inputId}`;
+ }, '')
+ .trim();
+
+ return ;
+}
+
+function LabelRange(props: any) {
+ const { id: idProp, ...otherProps } = props;
+
+ const defaultId = React.useId();
+ const labelId = idProp ?? defaultId;
+
+ return ;
+}
+
+const grey = {
+ 50: '#F3F6F9',
+ 100: '#E5EAF2',
+ 200: '#DAE2ED',
+ 300: '#C7D0DD',
+ 400: '#B0B8C4',
+ 500: '#9DA8B7',
+ 600: '#6B7A90',
+ 700: '#434D5B',
+ 800: '#303740',
+ 900: '#1C2025',
+};
+
+function useIsDarkMode() {
+ const theme = useTheme();
+ return theme.palette.mode === 'dark';
+}
+
+export function Styles() {
+ const isDarkMode = useIsDarkMode();
+ return (
+
+ );
+}
diff --git a/docs/translations/api-docs/slider-control/slider-control.json b/docs/translations/api-docs/slider-control/slider-control.json
new file mode 100644
index 0000000000..4bc12cf1e0
--- /dev/null
+++ b/docs/translations/api-docs/slider-control/slider-control.json
@@ -0,0 +1,10 @@
+{
+ "componentDescription": "",
+ "propDescriptions": {
+ "className": {
+ "description": "Class names applied to the element or a function that returns them based on the component's state."
+ },
+ "render": { "description": "A function to customize rendering of the component." }
+ },
+ "classDescriptions": {}
+}
diff --git a/docs/translations/api-docs/slider-indicator/slider-indicator.json b/docs/translations/api-docs/slider-indicator/slider-indicator.json
new file mode 100644
index 0000000000..4bc12cf1e0
--- /dev/null
+++ b/docs/translations/api-docs/slider-indicator/slider-indicator.json
@@ -0,0 +1,10 @@
+{
+ "componentDescription": "",
+ "propDescriptions": {
+ "className": {
+ "description": "Class names applied to the element or a function that returns them based on the component's state."
+ },
+ "render": { "description": "A function to customize rendering of the component." }
+ },
+ "classDescriptions": {}
+}
diff --git a/docs/translations/api-docs/slider-output/slider-output.json b/docs/translations/api-docs/slider-output/slider-output.json
new file mode 100644
index 0000000000..4bc12cf1e0
--- /dev/null
+++ b/docs/translations/api-docs/slider-output/slider-output.json
@@ -0,0 +1,10 @@
+{
+ "componentDescription": "",
+ "propDescriptions": {
+ "className": {
+ "description": "Class names applied to the element or a function that returns them based on the component's state."
+ },
+ "render": { "description": "A function to customize rendering of the component." }
+ },
+ "classDescriptions": {}
+}
diff --git a/docs/translations/api-docs/slider-root/slider-root.json b/docs/translations/api-docs/slider-root/slider-root.json
new file mode 100644
index 0000000000..2934c8c185
--- /dev/null
+++ b/docs/translations/api-docs/slider-root/slider-root.json
@@ -0,0 +1,58 @@
+{
+ "componentDescription": "",
+ "propDescriptions": {
+ "aria-labelledby": {
+ "description": "The id of the element containing a label for the slider."
+ },
+ "className": {
+ "description": "Class names applied to the element or a function that returns them based on the component's state."
+ },
+ "defaultValue": {
+ "description": "The default value of the slider. Use when the component is not controlled."
+ },
+ "direction": {
+ "description": "Sets the direction. For right-to-left languages, the lowest value is on the right-hand side."
+ },
+ "disabled": { "description": "/** If true
, the component is disabled." },
+ "largeStep": {
+ "description": "The granularity with which the slider can step through values when using Page Up/Page Down or Shift + Arrow Up/Arrow Down."
+ },
+ "max": {
+ "description": "The maximum allowed value of the slider. Should not be equal to min."
+ },
+ "min": {
+ "description": "The minimum allowed value of the slider. Should not be equal to max."
+ },
+ "minStepsBetweenValues": {
+ "description": "The minimum steps between values in a range slider."
+ },
+ "name": { "description": "Name attribute of the hidden input
element." },
+ "onValueChange": {
+ "description": "Callback function that is fired when the slider's value changed.",
+ "typeDescriptions": {
+ "value": "The new value.",
+ "activeThumb": "Index of the currently moved thumb.",
+ "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."
+ }
+ },
+ "onValueCommitted": {
+ "description": "Callback function that is fired when the pointerup
is triggered.",
+ "typeDescriptions": {
+ "value": "The new value.",
+ "event": "The event source of the callback. Warning: This is a generic event not a change event."
+ }
+ },
+ "orientation": { "description": "The component orientation." },
+ "render": { "description": "A function to customize rendering of the component." },
+ "step": {
+ "description": "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."
+ },
+ "tabIndex": {
+ "description": "Tab index attribute of the Thumb component's input
element."
+ },
+ "value": {
+ "description": "The value of the slider. For ranged sliders, provide an array with two values."
+ }
+ },
+ "classDescriptions": {}
+}
diff --git a/docs/translations/api-docs/slider-thumb/slider-thumb.json b/docs/translations/api-docs/slider-thumb/slider-thumb.json
new file mode 100644
index 0000000000..fdff1c0d4e
--- /dev/null
+++ b/docs/translations/api-docs/slider-thumb/slider-thumb.json
@@ -0,0 +1,25 @@
+{
+ "componentDescription": "",
+ "propDescriptions": {
+ "aria-label": { "description": "The label for the input element." },
+ "aria-valuetext": {
+ "description": "A string value that provides a user-friendly name for the current value of the slider."
+ },
+ "className": {
+ "description": "Class names applied to the element or a function that returns them based on the component's state."
+ },
+ "getAriaLabel": {
+ "description": "Accepts a function which returns a string value that provides a user-friendly name for the input associated with the thumb",
+ "typeDescriptions": { "index": "The index of the input" }
+ },
+ "getAriaValueText": {
+ "description": "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.",
+ "typeDescriptions": {
+ "value": "The thumb label's value to format.",
+ "index": "The thumb label's index to format."
+ }
+ },
+ "render": { "description": "A function to customize rendering of the component." }
+ },
+ "classDescriptions": {}
+}
diff --git a/docs/translations/api-docs/slider-track/slider-track.json b/docs/translations/api-docs/slider-track/slider-track.json
new file mode 100644
index 0000000000..4bc12cf1e0
--- /dev/null
+++ b/docs/translations/api-docs/slider-track/slider-track.json
@@ -0,0 +1,10 @@
+{
+ "componentDescription": "",
+ "propDescriptions": {
+ "className": {
+ "description": "Class names applied to the element or a function that returns them based on the component's state."
+ },
+ "render": { "description": "A function to customize rendering of the component." }
+ },
+ "classDescriptions": {}
+}
diff --git a/docs/translations/api-docs/slider/slider.json b/docs/translations/api-docs/slider/slider.json
deleted file mode 100644
index afc13eeec5..0000000000
--- a/docs/translations/api-docs/slider/slider.json
+++ /dev/null
@@ -1,81 +0,0 @@
-{
- "componentDescription": "",
- "propDescriptions": {
- "aria-label": { "description": "The label of the slider." },
- "aria-labelledby": {
- "description": "The id of the element containing a label for the slider."
- },
- "aria-valuetext": {
- "description": "A string value that provides a user-friendly name for the current value of the slider."
- },
- "defaultValue": {
- "description": "The default value. Use when the component is not controlled."
- },
- "disabled": { "description": "If true
, the component is disabled." },
- "disableSwap": {
- "description": "If true
, the active thumb doesn't swap when moving pointer over a thumb while dragging another thumb."
- },
- "getAriaLabel": {
- "description": "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.",
- "typeDescriptions": { "index": "The thumb label's index to format." }
- },
- "getAriaValueText": {
- "description": "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.",
- "typeDescriptions": {
- "value": "The thumb label's value to format.",
- "index": "The thumb label's index to format."
- }
- },
- "isRtl": {
- "description": "If true
the Slider will be rendered right-to-left (with the lowest value on the right-hand side)."
- },
- "marks": {
- "description": "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."
- },
- "max": {
- "description": "The maximum allowed value of the slider. Should not be equal to min."
- },
- "min": {
- "description": "The minimum allowed value of the slider. Should not be equal to max."
- },
- "name": { "description": "Name attribute of the hidden input
element." },
- "onChange": {
- "description": "Callback function that is fired when the slider's value changed.",
- "typeDescriptions": {
- "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.",
- "value": "The new value.",
- "activeThumb": "Index of the currently moved thumb."
- }
- },
- "onChangeCommitted": {
- "description": "Callback function that is fired when the mouseup
is triggered.",
- "typeDescriptions": {
- "event": "The event source of the callback. Warning: This is a generic event not a change event.",
- "value": "The new value."
- }
- },
- "orientation": { "description": "The component orientation." },
- "scale": { "description": "A transformation function, to change the scale of the slider." },
- "shiftStep": {
- "description": "The granularity with which the slider can step through values when using Page Up/Page Down or Shift + Arrow Up/Arrow Down."
- },
- "slotProps": { "description": "The props used for each slot inside the Slider." },
- "slots": {
- "description": "The components used for each slot inside the Slider. Either a string to use a HTML element or a component."
- },
- "step": {
- "description": "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."
- },
- "tabIndex": { "description": "Tab index attribute of the hidden input
element." },
- "track": {
- "description": "The track presentation:
\n\nnormal
the track will render a bar representing the slider value. \ninverted
the track will render a bar representing the remaining slider value. \nfalse
the track will render without a bar. \n
\n"
- },
- "value": {
- "description": "The value of the slider. For ranged sliders, provide an array with two values."
- },
- "valueLabelFormat": {
- "description": "The format function the value label's value.
When a function is provided, it should have the following signature:
- {number} value The value label's value to format - {number} index The value label's index to format"
- }
- },
- "classDescriptions": {}
-}
diff --git a/docs/translations/api-docs/use-slider-control/use-slider-control.json b/docs/translations/api-docs/use-slider-control/use-slider-control.json
new file mode 100644
index 0000000000..39b3910c11
--- /dev/null
+++ b/docs/translations/api-docs/use-slider-control/use-slider-control.json
@@ -0,0 +1,20 @@
+{
+ "hookDescription": "",
+ "parametersDescriptions": {
+ "areValuesEqual": {
+ "description": "A function that compares a new value with the internal value of the slider. The internal value is potentially unsorted, e.g. to support frozen arrays: https://github.com/mui/material-ui/pull/28472"
+ },
+ "minStepsBetweenValues": {
+ "description": "The minimum steps between values in a range slider."
+ },
+ "percentageValues": { "description": "The value(s) of the slider as percentages" },
+ "rootRef": { "description": "The ref attached to the control area of the Slider." },
+ "step": {
+ "description": "The step increment of the slider when incrementing or decrementing. It will snap to multiples of this value. Decimal values are supported."
+ },
+ "subitems": {
+ "description": "A map containing all the Thumb components registered to the slider"
+ }
+ },
+ "returnValueDescriptions": {}
+}
diff --git a/docs/translations/api-docs/use-slider-indicator/use-slider-indicator.json b/docs/translations/api-docs/use-slider-indicator/use-slider-indicator.json
new file mode 100644
index 0000000000..281a51aaac
--- /dev/null
+++ b/docs/translations/api-docs/use-slider-indicator/use-slider-indicator.json
@@ -0,0 +1,9 @@
+{
+ "hookDescription": "",
+ "parametersDescriptions": {
+ "axis": { "description": "The orientation of the slider." },
+ "orientation": { "description": "The component orientation." },
+ "percentageValues": { "description": "The value(s) of the slider as percentages" }
+ },
+ "returnValueDescriptions": {}
+}
diff --git a/docs/translations/api-docs/use-slider-output/use-slider-output.json b/docs/translations/api-docs/use-slider-output/use-slider-output.json
new file mode 100644
index 0000000000..b08c73fb3b
--- /dev/null
+++ b/docs/translations/api-docs/use-slider-output/use-slider-output.json
@@ -0,0 +1,9 @@
+{
+ "hookDescription": "",
+ "parametersDescriptions": {
+ "subitems": {
+ "description": "A map containing all the Thumb components registered to the slider"
+ }
+ },
+ "returnValueDescriptions": {}
+}
diff --git a/docs/translations/api-docs/use-slider/use-slider.json b/docs/translations/api-docs/use-slider-root/use-slider-root.json
similarity index 100%
rename from docs/translations/api-docs/use-slider/use-slider.json
rename to docs/translations/api-docs/use-slider-root/use-slider-root.json
diff --git a/docs/translations/api-docs/use-slider-thumb/use-slider-thumb.json b/docs/translations/api-docs/use-slider-thumb/use-slider-thumb.json
new file mode 100644
index 0000000000..dc299a5ed2
--- /dev/null
+++ b/docs/translations/api-docs/use-slider-thumb/use-slider-thumb.json
@@ -0,0 +1,34 @@
+{
+ "hookDescription": "",
+ "parametersDescriptions": {
+ "active": { "description": "The index of the active thumb." },
+ "aria-label": { "description": "The label for the input element." },
+ "aria-valuetext": {
+ "description": "A string value that provides a user-friendly name for the current value of the slider."
+ },
+ "axis": { "description": "The orientation of the slider." },
+ "getAriaLabel": {
+ "description": "Accepts a function which returns a string value that provides a user-friendly name for the input associated with the thumb"
+ },
+ "getAriaValueText": {
+ "description": "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."
+ },
+ "largeStep": {
+ "description": "The large step value of the slider when incrementing or decrementing while the shift key is held, or when using Page-Up or Page-Down keys. Snaps to multiples of this value."
+ },
+ "max": { "description": "The maximum allowed value of the slider." },
+ "min": { "description": "The minimum allowed value of the slider." },
+ "minStepsBetweenValues": {
+ "description": "The minimum steps between values in a range slider."
+ },
+ "orientation": { "description": "The component orientation." },
+ "percentageValues": { "description": "The value(s) of the slider as percentages" },
+ "step": {
+ "description": "The step increment of the slider when incrementing or decrementing. It will snap to multiples of this value. Decimal values are supported."
+ },
+ "values": { "description": "The value(s) of the slider" }
+ },
+ "returnValueDescriptions": {
+ "getThumbStyle": { "description": "Resolver for the thumb slot's style prop." }
+ }
+}
diff --git a/docs/translations/translations.json b/docs/translations/translations.json
index 655ce00c38..7be5cd967b 100644
--- a/docs/translations/translations.json
+++ b/docs/translations/translations.json
@@ -222,6 +222,7 @@
"inputs": "Inputs",
"/base-ui/react-checkbox": "Checkbox",
"/base-ui/react-number-field": "Number Field",
+ "/base-ui/react-slider": "Slider",
"/base-ui/react-switch": "Switch",
"data-display": "Data display",
"/base-ui/react-popover": "Popover",
diff --git a/packages/mui-base/src/Slider/Control/SliderControl.test.tsx b/packages/mui-base/src/Slider/Control/SliderControl.test.tsx
new file mode 100644
index 0000000000..581f318436
--- /dev/null
+++ b/packages/mui-base/src/Slider/Control/SliderControl.test.tsx
@@ -0,0 +1,72 @@
+import * as React from 'react';
+import { createRenderer } from '@mui/internal-test-utils';
+import * as Slider from '@base_ui/react/Slider';
+import { SliderProvider, type SliderProviderValue } from '@base_ui/react/Slider';
+import { describeConformance } from '../../../test/describeConformance';
+
+const NOOP = () => {};
+
+describe('', () => {
+ const { render } = createRenderer();
+
+ const testProviderValue: SliderProviderValue = {
+ active: -1,
+ areValuesEqual: () => true,
+ axis: 'horizontal',
+ changeValue: NOOP,
+ compoundComponentContextValue: {
+ registerItem: () => ({ id: 0, deregister: () => {} }),
+ getItemIndex: () => 0,
+ totalSubitemCount: 1,
+ },
+ dragging: false,
+ disabled: false,
+ getFingerNewValue: () => ({
+ newValue: 0,
+ activeIndex: 0,
+ newPercentageValue: 0,
+ }),
+ handleValueChange: NOOP,
+ direction: 'ltr',
+ largeStep: 10,
+ max: 100,
+ min: 0,
+ minStepsBetweenValues: 0,
+ orientation: 'horizontal',
+ ownerState: {
+ activeThumbIndex: -1,
+ disabled: false,
+ dragging: false,
+ direction: 'ltr',
+ max: 100,
+ min: 0,
+ minStepsBetweenValues: 0,
+ orientation: 'horizontal',
+ step: 1,
+ values: [0],
+ },
+ percentageValues: [0],
+ registerSliderControl: NOOP,
+ setActive: NOOP,
+ setDragging: NOOP,
+ setValueState: NOOP,
+ step: 1,
+ subitems: new Map(),
+ values: [0],
+ };
+
+ describeConformance(, () => ({
+ inheritComponent: 'span',
+ render: (node) => {
+ const { container, ...other } = render(
+ {node},
+ );
+
+ return { container, ...other };
+ },
+ refInstanceof: window.HTMLSpanElement,
+ skip: [
+ 'reactTestRenderer', // Need to be wrapped with SliderProvider
+ ],
+ }));
+});
diff --git a/packages/mui-base/src/Slider/Control/SliderControl.tsx b/packages/mui-base/src/Slider/Control/SliderControl.tsx
new file mode 100644
index 0000000000..b16b8bca60
--- /dev/null
+++ b/packages/mui-base/src/Slider/Control/SliderControl.tsx
@@ -0,0 +1,83 @@
+'use client';
+import * as React from 'react';
+import PropTypes from 'prop-types';
+import { useComponentRenderer } from '../../utils/useComponentRenderer';
+import { useSliderContext } from '../Root/SliderProvider';
+import { sliderStyleHookMapping } from '../Root/styleHooks';
+import { SliderControlProps } from './SliderControl.types';
+import { useSliderControl } from './useSliderControl';
+
+const SliderControl = React.forwardRef(function SliderControl(
+ props: SliderControlProps,
+ forwardedRef: React.ForwardedRef,
+) {
+ const { render: renderProp, className, ...otherProps } = props;
+
+ const {
+ areValuesEqual,
+ disabled,
+ dragging,
+ getFingerNewValue,
+ handleValueChange,
+ minStepsBetweenValues,
+ onValueCommitted,
+ ownerState,
+ percentageValues,
+ registerSliderControl,
+ setActive,
+ setDragging,
+ setValueState,
+ step,
+ subitems,
+ } = useSliderContext();
+
+ const { getRootProps } = useSliderControl({
+ areValuesEqual,
+ disabled,
+ dragging,
+ getFingerNewValue,
+ handleValueChange,
+ minStepsBetweenValues,
+ onValueCommitted,
+ percentageValues,
+ registerSliderControl,
+ rootRef: forwardedRef,
+ setActive,
+ setDragging,
+ setValueState,
+ step,
+ subitems,
+ });
+
+ const { renderElement } = useComponentRenderer({
+ propGetter: getRootProps,
+ render: renderProp ?? 'span',
+ ownerState,
+ className,
+ extraProps: otherProps,
+ customStyleHookMapping: sliderStyleHookMapping,
+ });
+
+ return renderElement();
+});
+
+SliderControl.propTypes /* remove-proptypes */ = {
+ // ┌────────────────────────────── Warning ──────────────────────────────┐
+ // │ These PropTypes are generated from the TypeScript type definitions. │
+ // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │
+ // └─────────────────────────────────────────────────────────────────────┘
+ /**
+ * @ignore
+ */
+ children: PropTypes.node,
+ /**
+ * Class names applied to the element or a function that returns them based on the component's state.
+ */
+ className: PropTypes.oneOfType([PropTypes.func, PropTypes.string]),
+ /**
+ * A function to customize rendering of the component.
+ */
+ render: PropTypes.oneOfType([PropTypes.element, PropTypes.func]),
+} as any;
+
+export { SliderControl };
diff --git a/packages/mui-base/src/Slider/Control/SliderControl.types.ts b/packages/mui-base/src/Slider/Control/SliderControl.types.ts
new file mode 100644
index 0000000000..b7b469d09d
--- /dev/null
+++ b/packages/mui-base/src/Slider/Control/SliderControl.types.ts
@@ -0,0 +1,34 @@
+import { BaseUIComponentProps } from '../../utils/types';
+import { SliderRootOwnerState, UseSliderReturnValue } from '../Root/SliderRoot.types';
+
+export interface SliderControlProps extends BaseUIComponentProps<'span', SliderRootOwnerState> {}
+
+export interface UseSliderControlParameters
+ extends Pick<
+ UseSliderReturnValue,
+ | 'areValuesEqual'
+ | 'disabled'
+ | 'dragging'
+ | 'getFingerNewValue'
+ | 'handleValueChange'
+ | 'minStepsBetweenValues'
+ | 'onValueCommitted'
+ | 'percentageValues'
+ | 'registerSliderControl'
+ | 'setActive'
+ | 'setDragging'
+ | 'setValueState'
+ | 'step'
+ | 'subitems'
+ > {
+ /**
+ * The ref attached to the control area of the Slider.
+ */
+ rootRef?: React.Ref;
+}
+
+export interface UseSliderControlReturnValue {
+ getRootProps: (
+ externalProps?: React.ComponentPropsWithRef<'span'>,
+ ) => React.ComponentPropsWithRef<'span'>;
+}
diff --git a/packages/mui-base/src/Slider/Control/useSliderControl.ts b/packages/mui-base/src/Slider/Control/useSliderControl.ts
new file mode 100644
index 0000000000..c9d70775b3
--- /dev/null
+++ b/packages/mui-base/src/Slider/Control/useSliderControl.ts
@@ -0,0 +1,288 @@
+import * as React from 'react';
+import { mergeReactProps } from '../../utils/mergeReactProps';
+import { ownerDocument } from '../../utils/owner';
+import { useForkRef } from '../../utils/useForkRef';
+import { useEventCallback } from '../../utils/useEventCallback';
+import { focusThumb, trackFinger, validateMinimumDistance } from '../Root/useSliderRoot';
+import { UseSliderControlParameters, UseSliderControlReturnValue } from './SliderControl.types';
+
+const INTENTIONAL_DRAG_COUNT_THRESHOLD = 2;
+/**
+ *
+ * Demos:
+ *
+ * - [Slider](https://mui.com/base-ui/react-slider/#hooks)
+ *
+ * API:
+ *
+ * - [useSliderControl API](https://mui.com/base-ui/react-slider/hooks-api/#use-slider-control)
+ */
+export function useSliderControl(
+ parameters: UseSliderControlParameters,
+): UseSliderControlReturnValue {
+ const {
+ areValuesEqual,
+ disabled,
+ dragging,
+ getFingerNewValue,
+ handleValueChange,
+ onValueCommitted,
+ minStepsBetweenValues,
+ percentageValues,
+ registerSliderControl,
+ rootRef: externalRef,
+ setActive,
+ setDragging,
+ setValueState,
+ step,
+ subitems,
+ } = parameters;
+
+ const controlRef = React.useRef(null);
+
+ const handleRootRef = useForkRef(externalRef, registerSliderControl, controlRef);
+
+ // A number that uniquely identifies the current finger in the touch session.
+ const touchIdRef = React.useRef();
+
+ const moveCountRef = React.useRef(0);
+
+ // offset distance between:
+ // 1. pointerDown coordinates and
+ // 2. the exact intersection of the center of the thumb and the track
+ const offsetRef = React.useRef(0);
+
+ const thumbRefs = React.useMemo(() => {
+ return Array.from(subitems).map((subitem) => {
+ const { ref } = subitem[1];
+ return ref.current;
+ });
+ }, [subitems]);
+
+ const handleTouchMove = useEventCallback((nativeEvent: TouchEvent | PointerEvent) => {
+ const finger = trackFinger(nativeEvent, touchIdRef);
+
+ if (!finger) {
+ return;
+ }
+
+ moveCountRef.current += 1;
+
+ // Cancel move in case some other element consumed a pointerup event and it was not fired.
+ // @ts-ignore buttons doesn't not exists on touch event
+ if (nativeEvent.type === 'pointermove' && nativeEvent.buttons === 0) {
+ // eslint-disable-next-line @typescript-eslint/no-use-before-define
+ handleTouchEnd(nativeEvent);
+ return;
+ }
+
+ const newFingerValue = getFingerNewValue({
+ finger,
+ move: true,
+ offset: offsetRef.current,
+ });
+
+ if (!newFingerValue) {
+ return;
+ }
+
+ const { newValue, activeIndex } = newFingerValue;
+
+ focusThumb({ sliderRef: controlRef, activeIndex, setActive });
+
+ if (validateMinimumDistance(newValue, step, minStepsBetweenValues)) {
+ setValueState(newValue);
+
+ if (!dragging && moveCountRef.current > INTENTIONAL_DRAG_COUNT_THRESHOLD) {
+ setDragging(true);
+ }
+
+ if (handleValueChange && !areValuesEqual(newValue)) {
+ handleValueChange(newValue, activeIndex, nativeEvent);
+ }
+ }
+ });
+
+ const handleTouchEnd = useEventCallback((nativeEvent: TouchEvent | PointerEvent) => {
+ const finger = trackFinger(nativeEvent, touchIdRef);
+ setDragging(false);
+
+ if (!finger) {
+ return;
+ }
+
+ const newFingerValue = getFingerNewValue({
+ finger,
+ move: true,
+ });
+
+ if (!newFingerValue) {
+ return;
+ }
+
+ setActive(-1);
+
+ if (onValueCommitted) {
+ onValueCommitted(newFingerValue.newValue, nativeEvent);
+ }
+
+ touchIdRef.current = undefined;
+
+ // eslint-disable-next-line @typescript-eslint/no-use-before-define
+ stopListening();
+ });
+
+ const handleTouchStart = useEventCallback((nativeEvent: TouchEvent) => {
+ if (disabled) {
+ return;
+ }
+
+ const touch = nativeEvent.changedTouches[0];
+
+ if (touch != null) {
+ touchIdRef.current = touch.identifier;
+ }
+
+ const finger = trackFinger(nativeEvent, touchIdRef);
+
+ if (finger !== false) {
+ const newFingerValue = getFingerNewValue({
+ finger,
+ });
+
+ if (!newFingerValue) {
+ return;
+ }
+ const { newValue, activeIndex } = newFingerValue;
+
+ focusThumb({ sliderRef: controlRef, activeIndex, setActive });
+
+ setValueState(newValue);
+
+ if (handleValueChange && !areValuesEqual(newValue)) {
+ handleValueChange(newValue, activeIndex, nativeEvent);
+ }
+ }
+
+ moveCountRef.current = 0;
+ const doc = ownerDocument(controlRef.current);
+ doc.addEventListener('touchmove', handleTouchMove, { passive: true });
+ doc.addEventListener('touchend', handleTouchEnd, { passive: true });
+ });
+
+ const stopListening = useEventCallback(() => {
+ offsetRef.current = 0;
+ const doc = ownerDocument(controlRef.current);
+ doc.removeEventListener('pointermove', handleTouchMove);
+ doc.removeEventListener('pointerup', handleTouchEnd);
+ doc.removeEventListener('touchmove', handleTouchMove);
+ doc.removeEventListener('touchend', handleTouchEnd);
+ });
+
+ React.useEffect(() => {
+ const { current: sliderControl } = controlRef;
+
+ if (!sliderControl) {
+ return () => stopListening();
+ }
+
+ sliderControl.addEventListener('touchstart', handleTouchStart, {
+ passive: true,
+ });
+
+ return () => {
+ sliderControl.removeEventListener('touchstart', handleTouchStart);
+
+ stopListening();
+ };
+ }, [stopListening, handleTouchStart, controlRef]);
+
+ React.useEffect(() => {
+ if (disabled) {
+ stopListening();
+ }
+ }, [disabled, stopListening]);
+
+ const getRootProps = React.useCallback(
+ (externalProps = {}) => {
+ return mergeReactProps(externalProps, {
+ onPointerDown(event: React.PointerEvent) {
+ 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, touchIdRef);
+
+ if (finger !== false) {
+ const newFingerValue = getFingerNewValue({
+ finger,
+ });
+
+ if (!newFingerValue) {
+ return;
+ }
+
+ const { newValue, activeIndex, newPercentageValue } = newFingerValue;
+
+ focusThumb({ sliderRef: controlRef, activeIndex, setActive });
+
+ // if the event lands on a thumb, don't change the value, just get the
+ // percentageValue difference represented by the distance between the click origin
+ // and the coordinates of the value on the track area
+ if (thumbRefs.includes(event.target as HTMLElement)) {
+ const targetThumbIndex = (event.target as HTMLElement).getAttribute('data-index');
+
+ const offset = percentageValues[Number(targetThumbIndex)] / 100 - newPercentageValue;
+
+ offsetRef.current = offset;
+ } else {
+ setValueState(newValue);
+
+ if (handleValueChange && !areValuesEqual(newValue)) {
+ handleValueChange(newValue, activeIndex, event);
+ }
+ }
+ }
+
+ moveCountRef.current = 0;
+ const doc = ownerDocument(controlRef.current);
+ doc.addEventListener('pointermove', handleTouchMove, { passive: true });
+ doc.addEventListener('pointerup', handleTouchEnd);
+ },
+ ref: handleRootRef,
+ });
+ },
+ [
+ areValuesEqual,
+ disabled,
+ getFingerNewValue,
+ handleRootRef,
+ handleTouchMove,
+ handleTouchEnd,
+ handleValueChange,
+ percentageValues,
+ setActive,
+ setValueState,
+ thumbRefs,
+ ],
+ );
+
+ return React.useMemo(
+ () => ({
+ getRootProps,
+ }),
+ [getRootProps],
+ );
+}
diff --git a/packages/mui-base/src/Slider/Indicator/SliderIndicator.test.tsx b/packages/mui-base/src/Slider/Indicator/SliderIndicator.test.tsx
new file mode 100644
index 0000000000..5e1526e1a8
--- /dev/null
+++ b/packages/mui-base/src/Slider/Indicator/SliderIndicator.test.tsx
@@ -0,0 +1,72 @@
+import * as React from 'react';
+import { createRenderer } from '@mui/internal-test-utils';
+import * as Slider from '@base_ui/react/Slider';
+import { SliderProvider, type SliderProviderValue } from '@base_ui/react/Slider';
+import { describeConformance } from '../../../test/describeConformance';
+
+const NOOP = () => {};
+
+describe('', () => {
+ const { render } = createRenderer();
+
+ const testProviderValue: SliderProviderValue = {
+ active: -1,
+ areValuesEqual: () => true,
+ axis: 'horizontal',
+ changeValue: NOOP,
+ compoundComponentContextValue: {
+ registerItem: () => ({ id: 0, deregister: () => {} }),
+ getItemIndex: () => 0,
+ totalSubitemCount: 1,
+ },
+ dragging: false,
+ disabled: false,
+ getFingerNewValue: () => ({
+ newValue: 0,
+ activeIndex: 0,
+ newPercentageValue: 0,
+ }),
+ handleValueChange: NOOP,
+ direction: 'ltr',
+ largeStep: 10,
+ max: 100,
+ min: 0,
+ minStepsBetweenValues: 0,
+ orientation: 'horizontal',
+ ownerState: {
+ activeThumbIndex: -1,
+ disabled: false,
+ dragging: false,
+ direction: 'ltr',
+ max: 100,
+ min: 0,
+ minStepsBetweenValues: 0,
+ orientation: 'horizontal',
+ step: 1,
+ values: [0],
+ },
+ percentageValues: [0],
+ registerSliderControl: NOOP,
+ setActive: NOOP,
+ setDragging: NOOP,
+ setValueState: NOOP,
+ step: 1,
+ subitems: new Map(),
+ values: [0],
+ };
+
+ describeConformance(, () => ({
+ inheritComponent: 'span',
+ render: (node) => {
+ const { container, ...other } = render(
+ {node},
+ );
+
+ return { container, ...other };
+ },
+ refInstanceof: window.HTMLSpanElement,
+ skip: [
+ 'reactTestRenderer', // Need to be wrapped with SliderProvider
+ ],
+ }));
+});
diff --git a/packages/mui-base/src/Slider/Indicator/SliderIndicator.tsx b/packages/mui-base/src/Slider/Indicator/SliderIndicator.tsx
new file mode 100644
index 0000000000..ee5e59e736
--- /dev/null
+++ b/packages/mui-base/src/Slider/Indicator/SliderIndicator.tsx
@@ -0,0 +1,59 @@
+'use client';
+import * as React from 'react';
+import PropTypes from 'prop-types';
+import { useComponentRenderer } from '../../utils/useComponentRenderer';
+import { useSliderContext } from '../Root/SliderProvider';
+import { sliderStyleHookMapping } from '../Root/styleHooks';
+import { SliderIndicatorProps } from './SliderIndicator.types';
+import { useSliderIndicator } from './useSliderIndicator';
+
+const SliderIndicator = React.forwardRef(function SliderIndicator(
+ props: SliderIndicatorProps,
+ forwardedRef: React.ForwardedRef,
+) {
+ const { render, className, ...otherProps } = props;
+
+ const { axis, direction, disabled, orientation, ownerState, percentageValues } =
+ useSliderContext();
+
+ const { getRootProps } = useSliderIndicator({
+ axis,
+ direction,
+ disabled,
+ orientation,
+ percentageValues,
+ });
+
+ const { renderElement } = useComponentRenderer({
+ propGetter: getRootProps,
+ render: render ?? 'span',
+ ownerState,
+ className,
+ ref: forwardedRef,
+ extraProps: otherProps,
+ customStyleHookMapping: sliderStyleHookMapping,
+ });
+
+ return renderElement();
+});
+
+SliderIndicator.propTypes /* remove-proptypes */ = {
+ // ┌────────────────────────────── Warning ──────────────────────────────┐
+ // │ These PropTypes are generated from the TypeScript type definitions. │
+ // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │
+ // └─────────────────────────────────────────────────────────────────────┘
+ /**
+ * @ignore
+ */
+ children: PropTypes.node,
+ /**
+ * Class names applied to the element or a function that returns them based on the component's state.
+ */
+ className: PropTypes.oneOfType([PropTypes.func, PropTypes.string]),
+ /**
+ * A function to customize rendering of the component.
+ */
+ render: PropTypes.oneOfType([PropTypes.element, PropTypes.func]),
+} as any;
+
+export { SliderIndicator };
diff --git a/packages/mui-base/src/Slider/Indicator/SliderIndicator.types.ts b/packages/mui-base/src/Slider/Indicator/SliderIndicator.types.ts
new file mode 100644
index 0000000000..f8916b0d35
--- /dev/null
+++ b/packages/mui-base/src/Slider/Indicator/SliderIndicator.types.ts
@@ -0,0 +1,16 @@
+import { BaseUIComponentProps } from '../../utils/types';
+import { SliderRootOwnerState, UseSliderReturnValue } from '../Root/SliderRoot.types';
+
+export interface SliderIndicatorProps extends BaseUIComponentProps<'span', SliderRootOwnerState> {}
+
+export interface UseSliderIndicatorParameters
+ extends Pick<
+ UseSliderReturnValue,
+ 'axis' | 'direction' | 'disabled' | 'orientation' | 'percentageValues'
+ > {}
+
+export interface UseSliderIndicatorReturnValue {
+ getRootProps: (
+ externalProps?: React.ComponentPropsWithRef<'span'>,
+ ) => React.ComponentPropsWithRef<'span'>;
+}
diff --git a/packages/mui-base/src/Slider/Indicator/useSliderIndicator.ts b/packages/mui-base/src/Slider/Indicator/useSliderIndicator.ts
new file mode 100644
index 0000000000..7909c9f0b6
--- /dev/null
+++ b/packages/mui-base/src/Slider/Indicator/useSliderIndicator.ts
@@ -0,0 +1,86 @@
+'use client';
+import * as React from 'react';
+import { mergeReactProps } from '../../utils/mergeReactProps';
+import {
+ UseSliderIndicatorParameters,
+ UseSliderIndicatorReturnValue,
+} from './SliderIndicator.types';
+
+const axisProps = {
+ horizontal: {
+ offset: (percent: number) => ({ left: `${percent}%` }),
+ leap: (percent: number) => ({ width: `${percent}%`, height: 'inherit' }),
+ },
+ 'horizontal-reverse': {
+ offset: (percent: number) => ({ right: `${percent}%` }),
+ leap: (percent: number) => ({ width: `${percent}%`, height: 'inherit' }),
+ },
+ vertical: {
+ offset: (percent: number) => ({ bottom: `${percent}%` }),
+ leap: (percent: number) => ({ height: `${percent}%`, width: 'inherit' }),
+ },
+};
+/**
+ *
+ * Demos:
+ *
+ * - [Slider](https://mui.com/base-ui/react-slider/#hooks)
+ *
+ * API:
+ *
+ * - [useSliderIndicator API](https://mui.com/base-ui/react-slider/hooks-api/#use-slider-indicator)
+ */
+function useSliderIndicator(
+ parameters: UseSliderIndicatorParameters,
+): UseSliderIndicatorReturnValue {
+ const { axis, direction, orientation, percentageValues } = parameters;
+
+ const isRange = percentageValues.length > 1;
+
+ const isRtl = direction === 'rtl';
+
+ let internalStyles;
+
+ if (isRange) {
+ const trackOffset = percentageValues[0];
+ const trackLeap = percentageValues[percentageValues.length - 1] - trackOffset;
+
+ internalStyles = {
+ position: 'absolute',
+ ...axisProps[axis].offset(trackOffset),
+ ...axisProps[axis].leap(trackLeap),
+ };
+ } else if (orientation === 'vertical') {
+ internalStyles = {
+ position: 'absolute',
+ bottom: 0,
+ height: `${percentageValues[0]}%`,
+ width: 'inherit',
+ };
+ } else {
+ internalStyles = {
+ position: 'absolute',
+ [isRtl ? 'right' : 'left']: 0,
+ width: `${percentageValues[0]}%`,
+ height: 'inherit',
+ };
+ }
+
+ const getRootProps = React.useCallback(
+ (externalProps = {}) => {
+ return mergeReactProps(externalProps, {
+ style: internalStyles,
+ });
+ },
+ [internalStyles],
+ );
+
+ return React.useMemo(
+ () => ({
+ getRootProps,
+ }),
+ [getRootProps],
+ );
+}
+
+export { useSliderIndicator };
diff --git a/packages/mui-base/src/Slider/Output/SliderOutput.test.tsx b/packages/mui-base/src/Slider/Output/SliderOutput.test.tsx
new file mode 100644
index 0000000000..a354e4a8b4
--- /dev/null
+++ b/packages/mui-base/src/Slider/Output/SliderOutput.test.tsx
@@ -0,0 +1,106 @@
+import * as React from 'react';
+import { expect } from 'chai';
+import { createRenderer } from '@mui/internal-test-utils';
+import * as Slider from '@base_ui/react/Slider';
+import { SliderProvider, type SliderProviderValue } from '@base_ui/react/Slider';
+import { describeConformance } from '../../../test/describeConformance';
+
+const NOOP = () => {};
+
+describe('', () => {
+ const { render } = createRenderer();
+
+ const testProviderValue: SliderProviderValue = {
+ active: -1,
+ areValuesEqual: () => true,
+ axis: 'horizontal',
+ changeValue: NOOP,
+ compoundComponentContextValue: {
+ registerItem: () => ({ id: 0, deregister: () => {} }),
+ getItemIndex: () => 0,
+ totalSubitemCount: 1,
+ },
+ dragging: false,
+ disabled: false,
+ getFingerNewValue: () => ({
+ newValue: 0,
+ activeIndex: 0,
+ newPercentageValue: 0,
+ }),
+ handleValueChange: NOOP,
+ direction: 'ltr',
+ largeStep: 10,
+ max: 100,
+ min: 0,
+ minStepsBetweenValues: 0,
+ orientation: 'horizontal',
+ ownerState: {
+ activeThumbIndex: -1,
+ disabled: false,
+ dragging: false,
+ direction: 'ltr',
+ max: 100,
+ min: 0,
+ minStepsBetweenValues: 0,
+ orientation: 'horizontal',
+ step: 1,
+ values: [0],
+ },
+ percentageValues: [0],
+ registerSliderControl: NOOP,
+ setActive: NOOP,
+ setDragging: NOOP,
+ setValueState: NOOP,
+ step: 1,
+ subitems: new Map(),
+ values: [0],
+ };
+
+ describeConformance(, () => ({
+ inheritComponent: 'output',
+ render: (node) => {
+ const { container, ...other } = render(
+ {node},
+ );
+
+ return { container, ...other };
+ },
+ refInstanceof: window.HTMLOutputElement,
+ skip: [
+ 'reactTestRenderer', // Need to be wrapped with SliderProvider
+ ],
+ }));
+
+ it('renders a single value', () => {
+ const { getByTestId } = render(
+
+
+ ,
+ );
+ const sliderOutput = getByTestId('output');
+
+ expect(sliderOutput).to.have.text('40');
+ });
+
+ it('renders a range', () => {
+ const { getByTestId } = render(
+
+
+ ,
+ );
+ const sliderOutput = getByTestId('output');
+
+ expect(sliderOutput).to.have.text('40 – 65');
+ });
+
+ it('renders all thumb values', () => {
+ const { getByTestId } = render(
+
+
+ ,
+ );
+ const sliderOutput = getByTestId('output');
+
+ expect(sliderOutput).to.have.text('40 – 60 – 80 – 95');
+ });
+});
diff --git a/packages/mui-base/src/Slider/Output/SliderOutput.tsx b/packages/mui-base/src/Slider/Output/SliderOutput.tsx
new file mode 100644
index 0000000000..3919e035df
--- /dev/null
+++ b/packages/mui-base/src/Slider/Output/SliderOutput.tsx
@@ -0,0 +1,57 @@
+'use client';
+import * as React from 'react';
+import PropTypes from 'prop-types';
+import { useComponentRenderer } from '../../utils/useComponentRenderer';
+import { useSliderContext } from '../Root/SliderProvider';
+import { sliderStyleHookMapping } from '../Root/styleHooks';
+import { SliderOutputProps } from './SliderOutput.types';
+import { useSliderOutput } from './useSliderOutput';
+
+const SliderOutput = React.forwardRef(function SliderOutput(
+ props: SliderOutputProps,
+ forwardedRef: React.ForwardedRef,
+) {
+ const { render, className, ...otherProps } = props;
+
+ const { ownerState, subitems, values } = useSliderContext();
+
+ const { getRootProps } = useSliderOutput({
+ subitems,
+ });
+
+ const { renderElement } = useComponentRenderer({
+ propGetter: getRootProps,
+ render: render ?? 'output',
+ ownerState,
+ className,
+ ref: forwardedRef,
+ extraProps: {
+ children: values.join(' – '),
+ ...otherProps,
+ },
+ customStyleHookMapping: sliderStyleHookMapping,
+ });
+
+ return renderElement();
+});
+
+SliderOutput.propTypes /* remove-proptypes */ = {
+ // ┌────────────────────────────── Warning ──────────────────────────────┐
+ // │ These PropTypes are generated from the TypeScript type definitions. │
+ // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │
+ // └─────────────────────────────────────────────────────────────────────┘
+ /**
+ * @ignore
+ */
+ children: PropTypes.node,
+ /**
+ * Class names applied to the element or a function that returns them based on the component's state.
+ */
+ className: PropTypes.oneOfType([PropTypes.func, PropTypes.string]),
+ /**
+ * A function to customize rendering of the component.
+ */
+ render: PropTypes.oneOfType([PropTypes.element, PropTypes.func]),
+} as any;
+
+export { SliderOutput };
diff --git a/packages/mui-base/src/Slider/Output/SliderOutput.types.ts b/packages/mui-base/src/Slider/Output/SliderOutput.types.ts
new file mode 100644
index 0000000000..a93f2cf1ae
--- /dev/null
+++ b/packages/mui-base/src/Slider/Output/SliderOutput.types.ts
@@ -0,0 +1,14 @@
+import { BaseUIComponentProps } from '../../utils/types';
+import { SliderRootOwnerState, UseSliderReturnValue } from '../Root/SliderRoot.types';
+
+export interface SliderOutputProps extends BaseUIComponentProps<'output', SliderRootOwnerState> {}
+
+export interface UseSliderOutputParameters extends Pick {
+ 'aria-live'?: React.AriaAttributes['aria-live'];
+}
+
+export interface UseSliderOutputReturnValue {
+ getRootProps: (
+ externalProps?: React.ComponentPropsWithRef<'output'>,
+ ) => React.ComponentPropsWithRef<'output'>;
+}
diff --git a/packages/mui-base/src/Slider/Output/useSliderOutput.ts b/packages/mui-base/src/Slider/Output/useSliderOutput.ts
new file mode 100644
index 0000000000..111b7e0473
--- /dev/null
+++ b/packages/mui-base/src/Slider/Output/useSliderOutput.ts
@@ -0,0 +1,42 @@
+'use client';
+import * as React from 'react';
+import { mergeReactProps } from '../../utils/mergeReactProps';
+import { UseSliderOutputParameters, UseSliderOutputReturnValue } from './SliderOutput.types';
+/**
+ *
+ * Demos:
+ *
+ * - [Slider](https://mui.com/base-ui/react-slider/#hooks)
+ *
+ * API:
+ *
+ * - [useSliderOutput API](https://mui.com/base-ui/react-slider/hooks-api/#use-slider-output)
+ */
+function useSliderOutput(parameters: UseSliderOutputParameters): UseSliderOutputReturnValue {
+ const { 'aria-live': ariaLive = 'off', subitems } = parameters;
+
+ const outputFor = Array.from(subitems.values()).reduce((acc, item) => {
+ return `${acc} ${item.inputId}`;
+ }, '');
+
+ const getRootProps = React.useCallback(
+ (externalProps = {}) => {
+ return mergeReactProps(externalProps, {
+ // off by default because it will keep announcing when the slider is being dragged
+ // and also when the value is changing (but not yet committed)
+ 'aria-live': ariaLive,
+ htmlFor: outputFor.trim(),
+ });
+ },
+ [ariaLive, outputFor],
+ );
+
+ return React.useMemo(
+ () => ({
+ getRootProps,
+ }),
+ [getRootProps],
+ );
+}
+
+export { useSliderOutput };
diff --git a/packages/mui-base/src/Slider/Root/SliderProvider.tsx b/packages/mui-base/src/Slider/Root/SliderProvider.tsx
new file mode 100644
index 0000000000..7e3e158a32
--- /dev/null
+++ b/packages/mui-base/src/Slider/Root/SliderProvider.tsx
@@ -0,0 +1,102 @@
+'use client';
+import * as React from 'react';
+import PropTypes from 'prop-types';
+import { CompoundComponentContext } from '../../useCompound';
+import { SliderContextValue, SliderProviderValue } from './SliderRoot.types';
+
+export interface SliderProviderProps {
+ value: SliderProviderValue;
+ children: React.ReactNode;
+}
+
+export const SliderContext = React.createContext(undefined);
+
+if (process.env.NODE_ENV !== 'production') {
+ SliderContext.displayName = 'SliderContext';
+}
+
+export function useSliderContext() {
+ const context = React.useContext(SliderContext);
+ if (context === undefined) {
+ throw new Error('useSliderContext must be used inside a Slider component');
+ }
+ return context;
+}
+
+/**
+ * Sets up contexts for the Slider and its subcomponents.
+ *
+ * @ignore - do not document.
+ */
+function SliderProvider(props: SliderProviderProps) {
+ const { value: valueProp, children } = props;
+
+ const { compoundComponentContextValue, ...contextValue } = valueProp;
+
+ return (
+
+ {children}
+
+ );
+}
+
+SliderProvider.propTypes /* remove-proptypes */ = {
+ // ┌────────────────────────────── Warning ──────────────────────────────┐
+ // │ These PropTypes are generated from the TypeScript type definitions. │
+ // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │
+ // └─────────────────────────────────────────────────────────────────────┘
+ /**
+ * @ignore
+ */
+ children: PropTypes.node,
+ /**
+ * @ignore
+ */
+ value: PropTypes /* @typescript-to-proptypes-ignore */.shape({
+ active: PropTypes.number.isRequired,
+ areValuesEqual: PropTypes.func.isRequired,
+ 'aria-labelledby': PropTypes.string,
+ axis: PropTypes.oneOf(['horizontal-reverse', 'horizontal', 'vertical']).isRequired,
+ changeValue: PropTypes.func.isRequired,
+ compoundComponentContextValue: PropTypes.shape({
+ getItemIndex: PropTypes.func.isRequired,
+ registerItem: PropTypes.func.isRequired,
+ totalSubitemCount: PropTypes.number.isRequired,
+ }).isRequired,
+ direction: PropTypes.oneOf(['ltr', 'rtl']).isRequired,
+ disabled: PropTypes.bool.isRequired,
+ dragging: PropTypes.bool.isRequired,
+ getFingerNewValue: PropTypes.func.isRequired,
+ handleValueChange: PropTypes.func.isRequired,
+ largeStep: PropTypes.number.isRequired,
+ max: PropTypes.number.isRequired,
+ min: PropTypes.number.isRequired,
+ minStepsBetweenValues: PropTypes.number.isRequired,
+ name: PropTypes.string,
+ onValueCommitted: PropTypes.func,
+ orientation: PropTypes.oneOf(['horizontal', 'vertical']).isRequired,
+ ownerState: PropTypes.shape({
+ activeThumbIndex: PropTypes.number.isRequired,
+ direction: PropTypes.oneOf(['ltr', 'rtl']).isRequired,
+ disabled: PropTypes.bool.isRequired,
+ dragging: PropTypes.bool.isRequired,
+ max: PropTypes.number.isRequired,
+ min: PropTypes.number.isRequired,
+ minStepsBetweenValues: PropTypes.number.isRequired,
+ orientation: PropTypes.oneOf(['horizontal', 'vertical']).isRequired,
+ step: PropTypes.number.isRequired,
+ values: PropTypes.arrayOf(PropTypes.number).isRequired,
+ }).isRequired,
+ percentageValues: PropTypes.arrayOf(PropTypes.number).isRequired,
+ registerSliderControl: PropTypes.func.isRequired,
+ setActive: PropTypes.func.isRequired,
+ setDragging: PropTypes.func.isRequired,
+ setValueState: PropTypes.func.isRequired,
+ step: PropTypes.number.isRequired,
+ subitems: PropTypes.object.isRequired,
+ tabIndex: PropTypes.number,
+ values: PropTypes.arrayOf(PropTypes.number).isRequired,
+ }).isRequired,
+} as any;
+
+export { SliderProvider };
diff --git a/packages/mui-base/src/Slider/Root/SliderRoot.test.tsx b/packages/mui-base/src/Slider/Root/SliderRoot.test.tsx
new file mode 100644
index 0000000000..b4fc1ecb20
--- /dev/null
+++ b/packages/mui-base/src/Slider/Root/SliderRoot.test.tsx
@@ -0,0 +1,1592 @@
+import { expect } from 'chai';
+import * as React from 'react';
+import { spy, stub } from 'sinon';
+import { act, createRenderer, fireEvent, screen } from '@mui/internal-test-utils';
+import * as Slider from '@base_ui/react/Slider';
+import { describeConformance } from '../../../test/describeConformance';
+import type { SliderRootProps } from './SliderRoot.types';
+
+type Touches = Array>;
+
+const GETBOUNDINGCLIENTRECT_HORIZONTAL_SLIDER_RETURN_VAL = {
+ width: 100,
+ height: 10,
+ bottom: 10,
+ left: 0,
+ x: 0,
+ y: 0,
+ top: 0,
+ right: 0,
+ toJSON() {},
+};
+
+function createTouches(touches: Touches) {
+ return {
+ changedTouches: touches.map(
+ (touch) =>
+ new Touch({
+ target: document.body,
+ ...touch,
+ }),
+ ),
+ };
+}
+
+function TestSlider(props: SliderRootProps) {
+ return (
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function TestRangeSlider(props: SliderRootProps) {
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+describe('', () => {
+ before(function beforeHook() {
+ if (typeof Touch === 'undefined') {
+ this.skip();
+ }
+
+ // PointerEvent not fully implemented in jsdom, causing
+ // fireEvent.pointer* to ignore options
+ // https://github.com/jsdom/jsdom/issues/2527
+ (window as any).PointerEvent = window.MouseEvent;
+ });
+
+ const { render } = createRenderer();
+
+ describeConformance(, () => ({
+ inheritComponent: 'div',
+ render,
+ refInstanceof: window.HTMLDivElement,
+ }));
+
+ it('renders a slider', () => {
+ render(
+
+
+
+
+
+
+
+
+ ,
+ );
+
+ expect(screen.getByRole('slider')).to.have.attribute('aria-valuenow', '30');
+ });
+
+ it('should not break when initial value is out of range', () => {
+ const { getByTestId } = render();
+
+ const sliderControl = getByTestId('control');
+
+ stub(sliderControl, 'getBoundingClientRect').callsFake(
+ () => GETBOUNDINGCLIENTRECT_HORIZONTAL_SLIDER_RETURN_VAL,
+ );
+
+ fireEvent.touchStart(
+ sliderControl,
+ createTouches([{ identifier: 1, clientX: 100, clientY: 0 }]),
+ );
+
+ fireEvent.touchMove(document.body, createTouches([{ identifier: 1, clientX: 20, clientY: 0 }]));
+ });
+
+ describe('ARIA attributes', () => {
+ it('it has the correct aria attributes', () => {
+ const { container, getByRole, getByTestId } = render(
+
+
+
+
+
+
+
+
+ ,
+ );
+
+ const root = getByTestId('root');
+ const slider = getByRole('slider');
+ const input = container.querySelector('input');
+
+ expect(root).to.have.attribute('aria-labelledby', 'labelId');
+
+ expect(slider).to.have.attribute('aria-valuenow', '30');
+ expect(slider).to.have.attribute('aria-valuemin', '0');
+ expect(slider).to.have.attribute('aria-valuemax', '100');
+ expect(slider).to.have.attribute('aria-orientation', 'horizontal');
+
+ expect(input).to.have.attribute('aria-labelledby', 'labelId');
+ expect(input).to.have.attribute('aria-valuenow', '30');
+ });
+
+ it('should update aria-valuenow', () => {
+ const { getByRole } = render();
+ const slider = getByRole('slider');
+ act(() => {
+ slider.focus();
+ });
+
+ fireEvent.change(slider, { target: { value: 51 } });
+ expect(slider).to.have.attribute('aria-valuenow', '51');
+
+ fireEvent.change(slider, { target: { value: 52 } });
+ expect(slider).to.have.attribute('aria-valuenow', '52');
+ });
+
+ it('should set default aria-valuetext on range slider thumbs', () => {
+ const { getByTestId } = render();
+
+ const thumbOne = getByTestId('thumb-0');
+ const thumbTwo = getByTestId('thumb-1');
+
+ expect(thumbOne.querySelector('input')).to.have.attribute('aria-valuetext', '44 start range');
+ expect(thumbTwo.querySelector('input')).to.have.attribute('aria-valuetext', '50 end range');
+ });
+ });
+
+ describe('rtl', () => {
+ it('should handle RTL', () => {
+ const handleValueChange = spy();
+ const { getByTestId } = render(
+ ,
+ );
+ const sliderControl = getByTestId('control');
+ const sliderThumb = getByTestId('thumb');
+ expect(sliderThumb.style.right).to.equal('30%');
+
+ stub(sliderControl, 'getBoundingClientRect').callsFake(
+ () => GETBOUNDINGCLIENTRECT_HORIZONTAL_SLIDER_RETURN_VAL,
+ );
+
+ fireEvent.touchStart(
+ sliderControl,
+ createTouches([{ identifier: 1, clientX: 20, clientY: 0 }]),
+ );
+
+ fireEvent.touchMove(
+ document.body,
+ createTouches([{ identifier: 1, clientX: 22, clientY: 0 }]),
+ );
+
+ expect(handleValueChange.callCount).to.equal(2);
+ expect(handleValueChange.args[0][0]).to.equal(80);
+ expect(handleValueChange.args[1][0]).to.equal(78);
+ });
+
+ it('increments on ArrowUp', () => {
+ const handleValueChange = spy();
+ const { container } = render(
+ ,
+ );
+
+ const input = container.querySelector('input');
+
+ fireEvent.keyDown(document.body, { key: 'TAB' });
+
+ act(() => {
+ (input as HTMLInputElement).focus();
+ });
+
+ fireEvent.keyDown(input!, { key: 'ArrowUp' });
+ expect(handleValueChange.callCount).to.equal(1);
+ expect(handleValueChange.args[0][0]).to.deep.equal(21);
+
+ fireEvent.keyDown(input!, { key: 'ArrowUp', shiftKey: true });
+ expect(handleValueChange.callCount).to.equal(2);
+ expect(handleValueChange.args[1][0]).to.deep.equal(31);
+ });
+
+ it('increments on ArrowLeft', () => {
+ const handleValueChange = spy();
+ const { container } = render(
+ ,
+ );
+
+ const input = container.querySelector('input');
+
+ fireEvent.keyDown(document.body, { key: 'TAB' });
+
+ act(() => {
+ (input as HTMLInputElement).focus();
+ });
+
+ fireEvent.keyDown(input!, { key: 'ArrowLeft' });
+ expect(handleValueChange.callCount).to.equal(1);
+ expect(handleValueChange.args[0][0]).to.deep.equal(21);
+
+ fireEvent.keyDown(input!, { key: 'ArrowLeft', shiftKey: true });
+ expect(handleValueChange.callCount).to.equal(2);
+ expect(handleValueChange.args[1][0]).to.deep.equal(31);
+ });
+
+ it('decrements on ArrowDown', () => {
+ const handleValueChange = spy();
+ const { container } = render(
+ ,
+ );
+
+ const input = container.querySelector('input');
+
+ fireEvent.keyDown(document.body, { key: 'TAB' });
+
+ act(() => {
+ (input as HTMLInputElement).focus();
+ });
+
+ fireEvent.keyDown(input!, { key: 'ArrowDown' });
+ expect(handleValueChange.callCount).to.equal(1);
+ expect(handleValueChange.args[0][0]).to.deep.equal(19);
+
+ fireEvent.keyDown(input!, { key: 'ArrowDown', shiftKey: true });
+ expect(handleValueChange.callCount).to.equal(2);
+ expect(handleValueChange.args[1][0]).to.deep.equal(9);
+ });
+
+ it('decrements on ArrowRight', () => {
+ const handleValueChange = spy();
+ const { container } = render(
+ ,
+ );
+
+ const input = container.querySelector('input');
+
+ fireEvent.keyDown(document.body, { key: 'TAB' });
+
+ act(() => {
+ (input as HTMLInputElement).focus();
+ });
+
+ fireEvent.keyDown(input!, { key: 'ArrowRight' });
+ expect(handleValueChange.callCount).to.equal(1);
+ expect(handleValueChange.args[0][0]).to.deep.equal(19);
+
+ fireEvent.keyDown(input!, { key: 'ArrowRight', shiftKey: true });
+ expect(handleValueChange.callCount).to.equal(2);
+ expect(handleValueChange.args[1][0]).to.deep.equal(9);
+ });
+ });
+
+ describe('prop: disabled', () => {
+ it('should render data-disabled on all subcomponents', () => {
+ const { getByTestId } = render(
+
+
+
+
+
+
+
+
+ ,
+ );
+
+ const root = getByTestId('root');
+ const output = getByTestId('output');
+ const control = getByTestId('control');
+ const track = getByTestId('track');
+ const indicator = getByTestId('indicator');
+ const thumb = getByTestId('thumb');
+
+ [root, output, control, track, indicator, thumb].forEach((subcomponent) => {
+ expect(subcomponent).to.have.attribute('data-disabled', 'true');
+ });
+ });
+
+ it('should not respond to drag events after becoming disabled', function test() {
+ // TODO: Don't skip once a fix for https://github.com/jsdom/jsdom/issues/3029 is released.
+ if (/jsdom/.test(window.navigator.userAgent)) {
+ this.skip();
+ }
+
+ const { getByRole, setProps, getByTestId } = render(
+ ,
+ );
+
+ const sliderControl = getByTestId('control');
+
+ stub(sliderControl, 'getBoundingClientRect').callsFake(
+ () => GETBOUNDINGCLIENTRECT_HORIZONTAL_SLIDER_RETURN_VAL,
+ );
+ fireEvent.touchStart(
+ sliderControl,
+ createTouches([{ identifier: 1, clientX: 21, clientY: 0 }]),
+ );
+
+ const thumb = getByRole('slider');
+
+ expect(thumb).to.have.attribute('aria-valuenow', '21');
+ expect(thumb).toHaveFocus();
+
+ setProps({ disabled: true });
+ expect(thumb).not.toHaveFocus();
+ // expect(thumb).not.to.have.class(classes.active);
+
+ fireEvent.touchMove(
+ sliderControl,
+ createTouches([{ identifier: 1, clientX: 30, clientY: 0 }]),
+ );
+
+ expect(thumb).to.have.attribute('aria-valuenow', '21');
+ });
+
+ it('should not respond to drag events if disabled', function test() {
+ // TODO: Don't skip once a fix for https://github.com/jsdom/jsdom/issues/3029 is released.
+ if (/jsdom/.test(window.navigator.userAgent)) {
+ this.skip();
+ }
+
+ const { getByRole, getByTestId } = render(
+ ,
+ );
+
+ const thumb = getByRole('slider');
+ const sliderControl = getByTestId('control');
+
+ stub(sliderControl, 'getBoundingClientRect').callsFake(
+ () => GETBOUNDINGCLIENTRECT_HORIZONTAL_SLIDER_RETURN_VAL,
+ );
+
+ fireEvent.touchStart(
+ sliderControl,
+ createTouches([{ identifier: 1, clientX: 21, clientY: 0 }]),
+ );
+
+ fireEvent.touchMove(
+ document.body,
+ createTouches([{ identifier: 1, clientX: 30, clientY: 0 }]),
+ );
+
+ fireEvent.touchEnd(
+ document.body,
+ createTouches([{ identifier: 1, clientX: 30, clientY: 0 }]),
+ );
+
+ expect(thumb).to.have.attribute('aria-valuenow', '21');
+ });
+ });
+
+ describe('prop: orientation', () => {
+ it('sets the orientation via ARIA', () => {
+ render();
+
+ const sliderRoot = screen.getByRole('slider');
+ expect(sliderRoot).to.have.attribute('aria-orientation', 'vertical');
+ });
+
+ it('sets the data-orientation attribute', () => {
+ const { getByTestId } = render();
+
+ const sliderRoot = screen.getByRole('group');
+ expect(sliderRoot).to.have.attribute('data-orientation', 'horizontal');
+ const sliderControl = getByTestId('control');
+ expect(sliderControl).to.have.attribute('data-orientation', 'horizontal');
+ const sliderOutput = getByTestId('output');
+ expect(sliderOutput).to.have.attribute('data-orientation', 'horizontal');
+ });
+
+ it('does not set the orientation via appearance for WebKit browsers', function test() {
+ if (/jsdom/.test(window.navigator.userAgent) || !/WebKit/.test(window.navigator.userAgent)) {
+ this.skip();
+ }
+
+ render();
+
+ const slider = screen.getByRole('slider');
+
+ expect(slider).to.have.property('tagName', 'INPUT');
+ expect(slider).to.have.property('type', 'range');
+ // Only relevant if we implement `[role="slider"]` with `input[type="range"]`
+ // We're not setting this by default because it changes horizontal keyboard navigation in WebKit: https://bugs.chromium.org/p/chromium/issues/detail?id=1162640
+ expect(slider).not.toHaveComputedStyle({ webkitAppearance: 'slider-vertical' });
+ });
+
+ it('should report the right position', () => {
+ const handleValueChange = spy();
+ const { getByTestId } = render(
+ ,
+ );
+
+ const sliderControl = getByTestId('control');
+ stub(sliderControl, 'getBoundingClientRect').callsFake(() => ({
+ width: 10,
+ height: 100,
+ bottom: 100,
+ left: 0,
+ x: 0,
+ y: 0,
+ top: 0,
+ right: 0,
+ toJSON() {},
+ }));
+
+ fireEvent.touchStart(
+ sliderControl,
+ createTouches([{ identifier: 1, clientX: 0, clientY: 20 }]),
+ );
+ fireEvent.touchMove(
+ document.body,
+ createTouches([{ identifier: 1, clientX: 0, clientY: 22 }]),
+ );
+
+ expect(handleValueChange.callCount).to.equal(2);
+ expect(handleValueChange.args[0][0]).to.equal(80);
+ expect(handleValueChange.args[1][0]).to.equal(78);
+ });
+ });
+
+ describe('prop: step', () => {
+ it('supports non-integer values', () => {
+ const { getByRole } = render(
+ ,
+ );
+ const slider = getByRole('slider');
+
+ act(() => {
+ slider.focus();
+ });
+
+ fireEvent.change(slider, { target: { value: '51.1' } });
+ expect(slider).to.have.attribute('aria-valuenow', '51.1');
+
+ fireEvent.change(slider, { target: { value: '0.00000005' } });
+ expect(slider).to.have.attribute('aria-valuenow', '5e-8');
+
+ fireEvent.change(slider, { target: { value: '1e-7' } });
+ expect(slider).to.have.attribute('aria-valuenow', '1e-7');
+ });
+
+ it('should round value to step precision', () => {
+ const { getByRole, getByTestId } = render(
+ ,
+ );
+ const slider = getByRole('slider');
+
+ act(() => {
+ slider.focus();
+ });
+
+ const sliderControl = getByTestId('control');
+ stub(sliderControl, 'getBoundingClientRect').callsFake(
+ () => GETBOUNDINGCLIENTRECT_HORIZONTAL_SLIDER_RETURN_VAL,
+ );
+
+ act(() => {
+ slider.focus();
+ });
+
+ expect(slider).to.have.attribute('aria-valuenow', '0.2');
+
+ fireEvent.touchStart(
+ sliderControl,
+ createTouches([{ identifier: 1, clientX: 20, clientY: 0 }]),
+ );
+
+ fireEvent.touchMove(
+ document.body,
+ createTouches([{ identifier: 1, clientX: 80, clientY: 0 }]),
+ );
+ expect(slider).to.have.attribute('aria-valuenow', '0.8');
+
+ fireEvent.touchMove(
+ document.body,
+ createTouches([{ identifier: 1, clientX: 40, clientY: 0 }]),
+ );
+ expect(slider).to.have.attribute('aria-valuenow', '0.4');
+ });
+
+ it('should not fail to round value to step precision when step is very small', () => {
+ const { getByRole, getByTestId } = render(
+ ,
+ );
+ const slider = getByRole('slider');
+
+ act(() => {
+ slider.focus();
+ });
+
+ const sliderControl = getByTestId('control');
+ stub(sliderControl, 'getBoundingClientRect').callsFake(
+ () => GETBOUNDINGCLIENTRECT_HORIZONTAL_SLIDER_RETURN_VAL,
+ );
+
+ act(() => {
+ slider.focus();
+ });
+
+ expect(slider).to.have.attribute('aria-valuenow', '2e-8');
+
+ fireEvent.touchStart(
+ sliderControl,
+ createTouches([{ identifier: 1, clientX: 20, clientY: 0 }]),
+ );
+
+ fireEvent.touchMove(
+ document.body,
+ createTouches([{ identifier: 1, clientX: 80, clientY: 0 }]),
+ );
+ expect(slider).to.have.attribute('aria-valuenow', '8e-8');
+ });
+
+ it('should not fail to round value to step precision when step is very small and negative', () => {
+ const { getByRole, getByTestId } = render(
+ ,
+ );
+ const slider = getByRole('slider');
+
+ act(() => {
+ slider.focus();
+ });
+
+ const sliderControl = getByTestId('control');
+ stub(sliderControl, 'getBoundingClientRect').callsFake(
+ () => GETBOUNDINGCLIENTRECT_HORIZONTAL_SLIDER_RETURN_VAL,
+ );
+
+ act(() => {
+ slider.focus();
+ });
+
+ expect(slider).to.have.attribute('aria-valuenow', '-2e-8');
+
+ fireEvent.touchStart(
+ sliderControl,
+ createTouches([{ identifier: 1, clientX: 80, clientY: 0 }]),
+ );
+
+ fireEvent.touchMove(
+ document.body,
+ createTouches([{ identifier: 1, clientX: 20, clientY: 0 }]),
+ );
+ expect(slider).to.have.attribute('aria-valuenow', '-8e-8');
+ });
+ });
+
+ describe('prop: max', () => {
+ const MAX = 750;
+
+ it('should set the max and aria-valuemax on the input', () => {
+ const { getByRole } = render(
+ ,
+ );
+ const slider = getByRole('slider');
+
+ expect(slider).to.have.attribute('aria-valuemax', String(MAX));
+ expect(slider).to.have.attribute('max', String(MAX));
+ });
+
+ it('should not go more than the max', () => {
+ const { getByRole } = render(
+ ,
+ );
+
+ const slider = getByRole('slider');
+ act(() => {
+ slider.focus();
+ });
+
+ fireEvent.change(slider, { target: { value: String(MAX + 100) } });
+ expect(slider).to.have.attribute('aria-valuenow', String(MAX));
+ });
+
+ it('should reach right edge value', () => {
+ const { getByRole, getByTestId } = render(
+ ,
+ );
+
+ const sliderControl = getByTestId('control');
+
+ stub(sliderControl, 'getBoundingClientRect').callsFake(
+ () => GETBOUNDINGCLIENTRECT_HORIZONTAL_SLIDER_RETURN_VAL,
+ );
+
+ const slider = getByRole('slider');
+ act(() => {
+ slider.focus();
+ });
+
+ expect(slider).to.have.attribute('aria-valuenow', '90');
+
+ fireEvent.touchStart(
+ sliderControl,
+ createTouches([{ identifier: 1, clientX: 20, clientY: 0 }]),
+ );
+
+ fireEvent.touchMove(
+ document.body,
+ createTouches([{ identifier: 1, clientX: 100, clientY: 0 }]),
+ );
+ expect(slider).to.have.attribute('aria-valuenow', '106');
+
+ fireEvent.touchMove(
+ document.body,
+ createTouches([{ identifier: 1, clientX: 200, clientY: 0 }]),
+ );
+ expect(slider).to.have.attribute('aria-valuenow', '106');
+
+ fireEvent.touchMove(
+ document.body,
+ createTouches([{ identifier: 1, clientX: 50, clientY: 0 }]),
+ );
+ expect(slider).to.have.attribute('aria-valuenow', '56');
+
+ fireEvent.touchMove(
+ document.body,
+ createTouches([{ identifier: 1, clientX: -100, clientY: 0 }]),
+ );
+ expect(slider).to.have.attribute('aria-valuenow', '6');
+ });
+ });
+
+ describe('prop: min', () => {
+ const MIN = 150;
+
+ it('should set the min and aria-valuemin on the input', () => {
+ const { getByRole } = render(
+ ,
+ );
+ const slider = getByRole('slider');
+
+ expect(slider).to.have.attribute('aria-valuemin', String(MIN));
+ expect(slider).to.have.attribute('min', String(MIN));
+ });
+
+ it('should use min as the step origin', () => {
+ const { getByRole } = render(
+ ,
+ );
+ const slider = getByRole('slider');
+ act(() => {
+ slider.focus();
+ });
+
+ expect(slider).to.have.attribute('aria-valuenow', String(MIN));
+ });
+
+ it('should not go less than the min', () => {
+ const { getByRole } = render(
+ ,
+ );
+ const slider = getByRole('slider');
+ act(() => {
+ slider.focus();
+ });
+
+ fireEvent.change(slider, { target: { value: String(MIN - 100) } });
+ expect(slider).to.have.attribute('aria-valuenow', String(MIN));
+ });
+ });
+
+ describe('prop: minStepsBetweenValues', () => {
+ it('should enforce a minimum difference between range slider values', () => {
+ const handleValueChange = spy();
+
+ const { getByTestId } = render(
+ ,
+ );
+
+ const thumbOne = getByTestId('thumb-0');
+ const thumbTwo = getByTestId('thumb-1');
+
+ act(() => {
+ thumbOne.focus();
+ });
+
+ fireEvent.keyDown(thumbOne, { key: 'ArrowUp' });
+ expect(handleValueChange.callCount).to.equal(1);
+ expect(handleValueChange.args[0][0]).to.deep.equal([46, 50]);
+ fireEvent.keyDown(thumbOne, { key: 'ArrowUp' });
+ expect(handleValueChange.callCount).to.equal(1);
+
+ act(() => {
+ thumbTwo.focus();
+ });
+
+ fireEvent.keyDown(thumbTwo, { key: 'ArrowUp' });
+ expect(handleValueChange.callCount).to.equal(2);
+ expect(handleValueChange.args[1][0]).to.deep.equal([46, 52]);
+ fireEvent.keyDown(thumbTwo, { key: 'ArrowDown' });
+ fireEvent.keyDown(thumbTwo, { key: 'ArrowDown' });
+ expect(handleValueChange.callCount).to.equal(3);
+ expect(handleValueChange.args[2][0]).to.deep.equal([46, 50]);
+ });
+ });
+
+ describe('events', () => {
+ it('should call handlers', () => {
+ const handleValueChange = spy();
+ const handleValueCommitted = spy();
+
+ const { getByRole, getByTestId } = render(
+ ,
+ );
+
+ const sliderControl = getByTestId('control');
+
+ stub(sliderControl, 'getBoundingClientRect').callsFake(
+ () => GETBOUNDINGCLIENTRECT_HORIZONTAL_SLIDER_RETURN_VAL,
+ );
+
+ const slider = getByRole('slider');
+
+ fireEvent.pointerDown(sliderControl, {
+ buttons: 1,
+ clientX: 10,
+ });
+ fireEvent.pointerUp(sliderControl, {
+ buttons: 1,
+ clientX: 10,
+ });
+
+ expect(handleValueChange.callCount).to.equal(1);
+ expect(handleValueChange.args[0][0]).to.equal(10);
+ expect(handleValueCommitted.callCount).to.equal(1);
+ expect(handleValueCommitted.args[0][0]).to.equal(10);
+
+ act(() => {
+ slider.focus();
+ });
+
+ fireEvent.change(slider, { target: { value: 23 } });
+ expect(handleValueChange.callCount).to.equal(2);
+ expect(handleValueCommitted.callCount).to.equal(2);
+ });
+
+ it('should support touch events', () => {
+ const handleValueChange = spy();
+ const { getByTestId } = render(
+ ,
+ );
+ const sliderControl = getByTestId('control');
+ stub(sliderControl, 'getBoundingClientRect').callsFake(
+ () => GETBOUNDINGCLIENTRECT_HORIZONTAL_SLIDER_RETURN_VAL,
+ );
+
+ fireEvent.touchStart(
+ sliderControl,
+ createTouches([{ identifier: 1, clientX: 20, clientY: 0 }]),
+ );
+
+ fireEvent.touchMove(
+ document.body,
+ createTouches([{ identifier: 1, clientX: 21, clientY: 0 }]),
+ );
+
+ fireEvent.touchEnd(
+ document.body,
+ createTouches([{ identifier: 1, clientX: 21, clientY: 0 }]),
+ );
+
+ fireEvent.touchStart(
+ sliderControl,
+ createTouches([{ identifier: 1, clientX: 21, clientY: 0 }]),
+ );
+
+ fireEvent.touchMove(
+ document.body,
+ createTouches([{ identifier: 1, clientX: 22, clientY: 0 }]),
+ );
+
+ fireEvent.touchEnd(
+ document.body,
+ createTouches([{ identifier: 1, clientX: 22, clientY: 0 }]),
+ );
+
+ fireEvent.touchStart(
+ sliderControl,
+ createTouches([{ identifier: 1, clientX: 22, clientY: 0 }]),
+ );
+
+ fireEvent.touchMove(
+ document.body,
+ createTouches([{ identifier: 1, clientX: 22.1, clientY: 0 }]),
+ );
+
+ fireEvent.touchEnd(
+ document.body,
+ createTouches([{ identifier: 1, clientX: 22.1, clientY: 0 }]),
+ );
+
+ expect(handleValueChange.callCount).to.equal(2);
+ expect(handleValueChange.args[0][0]).to.deep.equal([21, 30]);
+ expect(handleValueChange.args[1][0]).to.deep.equal([22, 30]);
+ });
+
+ it('should only listen to changes from the same touchpoint', () => {
+ const handleValueChange = spy();
+ const handleValueCommitted = spy();
+
+ const { getByTestId } = render(
+ ,
+ );
+
+ const sliderControl = getByTestId('control');
+
+ stub(sliderControl, 'getBoundingClientRect').callsFake(
+ () => GETBOUNDINGCLIENTRECT_HORIZONTAL_SLIDER_RETURN_VAL,
+ );
+
+ fireEvent.touchStart(
+ sliderControl,
+ createTouches([{ identifier: 1, clientX: 0, clientY: 0 }]),
+ );
+ expect(handleValueChange.callCount).to.equal(0);
+ expect(handleValueCommitted.callCount).to.equal(0);
+
+ fireEvent.touchStart(
+ document.body,
+ createTouches([{ identifier: 2, clientX: 40, clientY: 0 }]),
+ );
+ expect(handleValueChange.callCount).to.equal(0);
+ expect(handleValueCommitted.callCount).to.equal(0);
+
+ fireEvent.touchMove(
+ document.body,
+ createTouches([{ identifier: 1, clientX: 1, clientY: 0 }]),
+ );
+ expect(handleValueChange.callCount).to.equal(1);
+ expect(handleValueCommitted.callCount).to.equal(0);
+
+ fireEvent.touchMove(
+ document.body,
+ createTouches([{ identifier: 2, clientX: 41, clientY: 0 }]),
+ );
+ expect(handleValueChange.callCount).to.equal(1);
+ expect(handleValueCommitted.callCount).to.equal(0);
+
+ fireEvent.touchEnd(document.body, createTouches([{ identifier: 1, clientX: 2, clientY: 0 }]));
+ expect(handleValueChange.callCount).to.equal(1);
+ expect(handleValueCommitted.callCount).to.equal(1);
+ });
+
+ it('should hedge against a dropped mouseup event', () => {
+ const handleValueChange = spy();
+
+ const { getByTestId } = render();
+
+ const sliderControl = getByTestId('control');
+
+ stub(sliderControl, 'getBoundingClientRect').callsFake(
+ () => GETBOUNDINGCLIENTRECT_HORIZONTAL_SLIDER_RETURN_VAL,
+ );
+
+ fireEvent.pointerDown(sliderControl, {
+ buttons: 1,
+ clientX: 1,
+ });
+ expect(handleValueChange.callCount).to.equal(1);
+ expect(handleValueChange.args[0][0]).to.equal(1);
+
+ fireEvent.pointerMove(document.body, {
+ buttons: 1,
+ clientX: 10,
+ });
+ expect(handleValueChange.callCount).to.equal(2);
+ expect(handleValueChange.args[1][0]).to.equal(10);
+
+ fireEvent.pointerMove(document.body, {
+ buttons: 0,
+ clientX: 11,
+ });
+ // The mouse's button was released, stop the dragging session.
+ expect(handleValueChange.callCount).to.equal(2);
+ });
+
+ it('should focus the slider when touching', () => {
+ const { getByRole, getByTestId } = render();
+ const slider = getByRole('slider');
+ const sliderControl = getByTestId('control');
+
+ stub(sliderControl, 'getBoundingClientRect').callsFake(
+ () => GETBOUNDINGCLIENTRECT_HORIZONTAL_SLIDER_RETURN_VAL,
+ );
+
+ fireEvent.touchStart(
+ sliderControl,
+ createTouches([{ identifier: 1, clientX: 0, clientY: 0 }]),
+ );
+
+ expect(slider).toHaveFocus();
+ });
+
+ it('should focus the slider when dragging', () => {
+ const { getByRole, getByTestId } = render();
+ const slider = getByRole('slider');
+ const sliderThumb = getByTestId('thumb');
+ const sliderControl = getByTestId('control');
+
+ stub(sliderControl, 'getBoundingClientRect').callsFake(
+ () => GETBOUNDINGCLIENTRECT_HORIZONTAL_SLIDER_RETURN_VAL,
+ );
+
+ fireEvent.pointerDown(sliderThumb, {
+ buttons: 1,
+ clientX: 1,
+ });
+
+ expect(slider).toHaveFocus();
+ });
+
+ it('should not override the event.target on touch events', () => {
+ const handleValueChange = spy();
+ const handleNativeEvent = spy();
+ const handleEvent = spy();
+ function Test() {
+ React.useEffect(() => {
+ document.addEventListener('touchstart', handleNativeEvent);
+ return () => {
+ document.removeEventListener('touchstart', handleNativeEvent);
+ };
+ });
+
+ return (
+
+
+
+ );
+ }
+
+ const { getByTestId } = render();
+ const sliderControl = getByTestId('control');
+
+ stub(sliderControl, 'getBoundingClientRect').callsFake(
+ () => GETBOUNDINGCLIENTRECT_HORIZONTAL_SLIDER_RETURN_VAL,
+ );
+
+ fireEvent.touchStart(
+ sliderControl,
+ createTouches([{ identifier: 1, clientX: 0, clientY: 0 }]),
+ );
+
+ expect(handleValueChange.callCount).to.equal(0);
+ expect(handleNativeEvent.callCount).to.equal(1);
+ expect(handleNativeEvent.firstCall.args[0]).to.have.property('target', sliderControl);
+ expect(handleEvent.callCount).to.equal(1);
+ expect(handleEvent.firstCall.args[0]).to.have.property('target', sliderControl);
+ });
+
+ it('should not override the event.target on mouse events', () => {
+ const handleValueChange = spy();
+ const handleNativeEvent = spy();
+ const handleEvent = spy();
+ function Test() {
+ React.useEffect(() => {
+ document.addEventListener('mousedown', handleNativeEvent);
+ return () => {
+ document.removeEventListener('mousedown', handleNativeEvent);
+ };
+ });
+
+ return (
+
+
+
+ );
+ }
+ const { getByTestId } = render();
+ const sliderControl = getByTestId('control');
+
+ stub(sliderControl, 'getBoundingClientRect').callsFake(
+ () => GETBOUNDINGCLIENTRECT_HORIZONTAL_SLIDER_RETURN_VAL,
+ );
+
+ fireEvent.mouseDown(sliderControl);
+
+ expect(handleValueChange.callCount).to.equal(0);
+ expect(handleNativeEvent.callCount).to.equal(1);
+ expect(handleNativeEvent.firstCall.args[0]).to.have.property('target', sliderControl);
+ expect(handleEvent.callCount).to.equal(1);
+ expect(handleEvent.firstCall.args[0]).to.have.property('target', sliderControl);
+ });
+ });
+
+ describe('dragging state', () => {
+ it('should not apply data-dragging for click modality', () => {
+ const { getByTestId } = render();
+
+ const sliderControl = getByTestId('control');
+
+ stub(sliderControl, 'getBoundingClientRect').callsFake(
+ () => GETBOUNDINGCLIENTRECT_HORIZONTAL_SLIDER_RETURN_VAL,
+ );
+
+ fireEvent.touchStart(
+ sliderControl,
+ createTouches([{ identifier: 1, clientX: 20, clientY: 0 }]),
+ );
+ fireEvent.touchMove(
+ document.body,
+ createTouches([{ identifier: 1, clientX: 21, clientY: 0 }]),
+ );
+ expect(sliderControl).to.not.have.attribute('data-dragging');
+ fireEvent.touchEnd(document.body, createTouches([{ identifier: 1, clientX: 0, clientY: 0 }]));
+ });
+
+ it('should apply data-dragging for dragging modality', () => {
+ const { getByTestId } = render();
+
+ const sliderControl = getByTestId('control');
+
+ stub(sliderControl, 'getBoundingClientRect').callsFake(
+ () => GETBOUNDINGCLIENTRECT_HORIZONTAL_SLIDER_RETURN_VAL,
+ );
+
+ fireEvent.touchStart(
+ sliderControl,
+ createTouches([{ identifier: 1, clientX: 20, clientY: 0 }]),
+ );
+ fireEvent.touchMove(
+ document.body,
+ createTouches([{ identifier: 1, clientX: 200, clientY: 0 }]),
+ );
+ fireEvent.touchMove(
+ document.body,
+ createTouches([{ identifier: 1, clientX: 200, clientY: 0 }]),
+ );
+
+ expect(sliderControl).to.not.have.attribute('data-dragging');
+
+ fireEvent.touchMove(
+ document.body,
+ createTouches([{ identifier: 1, clientX: 200, clientY: 0 }]),
+ );
+
+ expect(sliderControl).to.have.attribute('data-dragging', 'true');
+ fireEvent.touchEnd(document.body, createTouches([{ identifier: 1, clientX: 0, clientY: 0 }]));
+ expect(sliderControl).to.not.have.attribute('data-dragging');
+ });
+ });
+
+ describe('form submission', () => {
+ // doesn't work with two `` elements with the same name attribute
+ it('includes the slider value in formData when the `name` attribute is provided', function test() {
+ if (/jsdom/.test(window.navigator.userAgent)) {
+ // FormData is not available in JSDOM
+ this.skip();
+ }
+
+ const handleSubmit = (event: React.FormEvent) => {
+ event.preventDefault();
+ const formData = new FormData(event.currentTarget);
+ expect(formData.get('sliderField')).to.equal('51');
+
+ // @ts-ignore
+ const formDataAsObject = Object.fromEntries(formData.entries());
+ expect(Object.keys(formDataAsObject).length).to.equal(1);
+ };
+
+ const { getByText } = render(
+ ,
+ );
+
+ const button = getByText('Submit');
+ act(() => {
+ button.click();
+ });
+ });
+ });
+
+ describe('prop: onValueChange', () => {
+ it('is called when clicking on the control', () => {
+ const handleValueChange = spy();
+ render();
+
+ const sliderControl = screen.getByTestId('control');
+
+ stub(sliderControl, 'getBoundingClientRect').callsFake(
+ () => GETBOUNDINGCLIENTRECT_HORIZONTAL_SLIDER_RETURN_VAL,
+ );
+
+ fireEvent.pointerDown(sliderControl, {
+ buttons: 1,
+ clientX: 41,
+ });
+
+ expect(handleValueChange.callCount).to.equal(1);
+ });
+
+ it('is not called when clicking on the thumb', () => {
+ const handleValueChange = spy();
+ render();
+
+ const sliderControl = screen.getByTestId('control');
+ const sliderThumb = screen.getByTestId('thumb');
+
+ stub(sliderControl, 'getBoundingClientRect').callsFake(
+ () => GETBOUNDINGCLIENTRECT_HORIZONTAL_SLIDER_RETURN_VAL,
+ );
+
+ fireEvent.pointerDown(sliderThumb, {
+ buttons: 1,
+ clientX: 51,
+ });
+
+ expect(handleValueChange.callCount).to.equal(0);
+ });
+
+ it('should not react to right clicks', () => {
+ const handleValueChange = spy();
+ render();
+
+ const sliderControl = screen.getByTestId('control');
+
+ stub(sliderControl, 'getBoundingClientRect').callsFake(
+ () => GETBOUNDINGCLIENTRECT_HORIZONTAL_SLIDER_RETURN_VAL,
+ );
+
+ fireEvent.pointerDown(sliderControl, {
+ button: 2,
+ clientX: 41,
+ });
+
+ expect(handleValueChange.callCount).to.equal(0);
+ });
+
+ it('should fire only when the value changes', () => {
+ const handleValueChange = spy();
+ render();
+
+ const sliderControl = screen.getByTestId('control');
+
+ stub(sliderControl, 'getBoundingClientRect').callsFake(
+ () => GETBOUNDINGCLIENTRECT_HORIZONTAL_SLIDER_RETURN_VAL,
+ );
+
+ fireEvent.pointerDown(sliderControl, {
+ buttons: 1,
+ clientX: 21,
+ });
+
+ fireEvent.pointerMove(document.body, {
+ buttons: 1,
+ clientX: 22,
+ });
+ // Sometimes another event with the same position is fired by the browser.
+ fireEvent.pointerMove(document.body, {
+ buttons: 1,
+ clientX: 22,
+ });
+
+ expect(handleValueChange.callCount).to.equal(2);
+ expect(handleValueChange.args[0][0]).to.deep.equal(21);
+ expect(handleValueChange.args[1][0]).to.deep.equal(22);
+ });
+
+ type Values = Array<[string, number[]]>;
+
+ const values = [
+ ['readonly range', Object.freeze([2, 1])],
+ ['range', [2, 1]],
+ ] as Values;
+ values.forEach(([valueLabel, value]) => {
+ it(`is called even if the ${valueLabel} did not change`, () => {
+ const handleValueChange = spy();
+
+ render();
+
+ const sliderControl = screen.getByTestId('control');
+
+ stub(sliderControl, 'getBoundingClientRect').callsFake(
+ () => GETBOUNDINGCLIENTRECT_HORIZONTAL_SLIDER_RETURN_VAL,
+ );
+
+ // pixel: 0 20 40 60 80 100
+ // slider: |---|---|---|---|---|
+ // values: 0 1 2 3 4 5
+ // value:
+ // mouse:
+
+ fireEvent.pointerDown(sliderControl, {
+ buttons: 1,
+ clientX: 41,
+ });
+
+ expect(handleValueChange.callCount).to.equal(1);
+ expect(handleValueChange.args[0][0]).not.to.equal(value);
+ expect(handleValueChange.args[0][0]).to.deep.equal(value.slice().sort((a, b) => a - b));
+ });
+ });
+
+ it('should pass "name" and "value" as part of the event.target for onValueChange', () => {
+ const handleValueChange = stub().callsFake((newValue, thumbIndex, event) => event.target);
+
+ const { getByRole } = render(
+ ,
+ );
+ const slider = getByRole('slider');
+
+ act(() => {
+ slider.focus();
+ });
+ fireEvent.change(slider, {
+ target: {
+ value: 4,
+ },
+ });
+
+ expect(handleValueChange.callCount).to.equal(1);
+ const target = handleValueChange.firstCall.returnValue;
+ expect(target).to.deep.equal({
+ name: 'change-testing',
+ value: 4,
+ });
+ });
+ });
+
+ describe('keyboard interactions', () => {
+ it('increments on ArrowUp', () => {
+ const handleValueChange = spy();
+ const { container } = render(
+ ,
+ );
+
+ const input = container.querySelector('input');
+
+ fireEvent.keyDown(document.body, { key: 'TAB' });
+
+ act(() => {
+ (input as HTMLInputElement).focus();
+ });
+
+ fireEvent.keyDown(input!, { key: 'ArrowUp' });
+ expect(handleValueChange.callCount).to.equal(1);
+ expect(handleValueChange.args[0][0]).to.deep.equal(21);
+
+ fireEvent.keyDown(input!, { key: 'ArrowUp', shiftKey: true });
+ expect(handleValueChange.callCount).to.equal(2);
+ expect(handleValueChange.args[1][0]).to.deep.equal(31);
+ });
+
+ it('increments on ArrowRight', () => {
+ const handleValueChange = spy();
+ const { container } = render(
+ ,
+ );
+
+ const input = container.querySelector('input');
+
+ fireEvent.keyDown(document.body, { key: 'TAB' });
+
+ act(() => {
+ (input as HTMLInputElement).focus();
+ });
+
+ fireEvent.keyDown(input!, { key: 'ArrowRight' });
+ expect(handleValueChange.callCount).to.equal(1);
+ expect(handleValueChange.args[0][0]).to.deep.equal(21);
+
+ fireEvent.keyDown(input!, { key: 'ArrowRight', shiftKey: true });
+ expect(handleValueChange.callCount).to.equal(2);
+ expect(handleValueChange.args[1][0]).to.deep.equal(31);
+ });
+
+ it('decrements on ArrowDown', () => {
+ const handleValueChange = spy();
+ const { container } = render(
+ ,
+ );
+
+ const input = container.querySelector('input');
+
+ fireEvent.keyDown(document.body, { key: 'TAB' });
+
+ act(() => {
+ (input as HTMLInputElement).focus();
+ });
+
+ fireEvent.keyDown(input!, { key: 'ArrowDown' });
+ expect(handleValueChange.callCount).to.equal(1);
+ expect(handleValueChange.args[0][0]).to.deep.equal(19);
+
+ fireEvent.keyDown(input!, { key: 'ArrowDown', shiftKey: true });
+ expect(handleValueChange.callCount).to.equal(2);
+ expect(handleValueChange.args[1][0]).to.deep.equal(9);
+ });
+
+ it('decrements on ArrowLeft', () => {
+ const handleValueChange = spy();
+ const { container } = render(
+ ,
+ );
+
+ const input = container.querySelector('input');
+
+ fireEvent.keyDown(document.body, { key: 'TAB' });
+
+ act(() => {
+ (input as HTMLInputElement).focus();
+ });
+
+ fireEvent.keyDown(input!, { key: 'ArrowLeft' });
+ expect(handleValueChange.callCount).to.equal(1);
+ expect(handleValueChange.args[0][0]).to.deep.equal(19);
+
+ fireEvent.keyDown(input!, { key: 'ArrowLeft', shiftKey: true });
+ expect(handleValueChange.callCount).to.equal(2);
+ expect(handleValueChange.args[1][0]).to.deep.equal(9);
+ });
+
+ describe('key: Home', () => {
+ it('sets value to max in a single value slider', () => {
+ const handleValueChange = spy();
+ const { container } = render(
+ ,
+ );
+
+ const input = container.querySelector('input');
+
+ fireEvent.keyDown(document.body, { key: 'TAB' });
+
+ act(() => {
+ (input as HTMLInputElement).focus();
+ });
+
+ fireEvent.keyDown(input!, { key: 'Home' });
+ expect(handleValueChange.callCount).to.equal(1);
+ expect(handleValueChange.args[0][0]).to.deep.equal(77);
+ });
+
+ it('sets value to the maximum possible value in a range slider', () => {
+ const handleValueChange = spy();
+ const { getByTestId } = render(
+ ,
+ );
+
+ const thumbOne = getByTestId('thumb-0');
+ const thumbTwo = getByTestId('thumb-1');
+
+ act(() => {
+ thumbOne.focus();
+ });
+
+ fireEvent.keyDown(thumbOne, { key: 'Home' });
+ expect(handleValueChange.callCount).to.equal(1);
+ expect(handleValueChange.args[0][0]).to.deep.equal([50, 50]);
+ fireEvent.keyDown(thumbOne, { key: 'Home' });
+ expect(handleValueChange.callCount).to.equal(1);
+
+ act(() => {
+ thumbTwo.focus();
+ });
+
+ fireEvent.keyDown(thumbTwo, { key: 'Home' });
+ expect(handleValueChange.callCount).to.equal(2);
+ expect(handleValueChange.args[1][0]).to.deep.equal([50, 77]);
+ });
+ });
+
+ describe('key: End', () => {
+ it('sets value to min on End', () => {
+ const handleValueChange = spy();
+ const { container } = render(
+ ,
+ );
+
+ const input = container.querySelector('input');
+
+ fireEvent.keyDown(document.body, { key: 'TAB' });
+
+ act(() => {
+ (input as HTMLInputElement).focus();
+ });
+
+ fireEvent.keyDown(input!, { key: 'End' });
+ expect(handleValueChange.callCount).to.equal(1);
+ expect(handleValueChange.args[0][0]).to.deep.equal(17);
+ });
+
+ it('sets value to the minimum possible value in a range slider', () => {
+ const handleValueChange = spy();
+ const { getByTestId } = render(
+ ,
+ );
+
+ const thumbOne = getByTestId('thumb-0');
+ const thumbTwo = getByTestId('thumb-1');
+
+ act(() => {
+ thumbTwo.focus();
+ });
+
+ fireEvent.keyDown(thumbTwo, { key: 'End' });
+ expect(handleValueChange.callCount).to.equal(1);
+ expect(handleValueChange.args[0][0]).to.deep.equal([20, 20]);
+ fireEvent.keyDown(thumbTwo, { key: 'End' });
+ expect(handleValueChange.callCount).to.equal(1);
+
+ act(() => {
+ thumbOne.focus();
+ });
+
+ fireEvent.keyDown(thumbOne, { key: 'End' });
+ expect(handleValueChange.callCount).to.equal(2);
+ expect(handleValueChange.args[1][0]).to.deep.equal([7, 20]);
+ });
+ });
+
+ it('should support Shift + Left Arrow / Right Arrow keys', () => {
+ const handleValueChange = spy();
+ const { container } = render(
+ ,
+ );
+
+ const input = container.querySelector('input');
+
+ fireEvent.keyDown(document.body, { key: 'TAB' });
+
+ act(() => {
+ (input as HTMLInputElement).focus();
+ });
+
+ fireEvent.keyDown(input!, { key: 'ArrowLeft', shiftKey: true });
+ expect(handleValueChange.callCount).to.equal(1);
+ expect(handleValueChange.args[0][0]).to.deep.equal(10);
+
+ fireEvent.keyDown(input!, { key: 'ArrowRight', shiftKey: true });
+ expect(handleValueChange.callCount).to.equal(2);
+ expect(handleValueChange.args[1][0]).to.deep.equal(20);
+ });
+
+ it('should support Shift + Up Arrow / Down Arrow keys', () => {
+ const handleValueChange = spy();
+ const { container } = render(
+ ,
+ );
+
+ const input = container.querySelector('input');
+
+ fireEvent.keyDown(document.body, { key: 'TAB' });
+ act(() => {
+ (input as HTMLInputElement).focus();
+ });
+
+ fireEvent.keyDown(input!, { key: 'ArrowDown', shiftKey: true });
+ expect(handleValueChange.callCount).to.equal(1);
+ expect(handleValueChange.args[0][0]).to.deep.equal(10);
+
+ fireEvent.keyDown(input!, { key: 'ArrowUp', shiftKey: true });
+ expect(handleValueChange.callCount).to.equal(2);
+ expect(handleValueChange.args[1][0]).to.deep.equal(20);
+ });
+
+ it('should support PageUp / PageDown keys', () => {
+ const handleValueChange = spy();
+ const { container } = render(
+ ,
+ );
+
+ const input = container.querySelector('input');
+
+ fireEvent.keyDown(document.body, { key: 'TAB' });
+ act(() => {
+ (input as HTMLInputElement).focus();
+ });
+
+ fireEvent.keyDown(input!, { key: 'PageDown' });
+ expect(handleValueChange.callCount).to.equal(1);
+ expect(handleValueChange.args[0][0]).to.deep.equal(10);
+
+ fireEvent.keyDown(input!, { key: 'PageUp' });
+ expect(handleValueChange.callCount).to.equal(2);
+ expect(handleValueChange.args[1][0]).to.deep.equal(20);
+ });
+
+ it('should support Shift + Left Arrow / Right Arrow keys by taking acount step and largeStep', () => {
+ const handleValueChange = spy();
+ const DEFAULT_VALUE = 20;
+ const LARGE_STEP = 15;
+ const STEP = 5;
+ const { container } = render(
+ ,
+ );
+
+ const input = container.querySelector('input');
+
+ fireEvent.keyDown(document.body, { key: 'TAB' });
+ act(() => {
+ (input as HTMLInputElement).focus();
+ });
+
+ fireEvent.keyDown(input!, { key: 'ArrowLeft', shiftKey: true });
+ expect(handleValueChange.callCount).to.equal(1);
+ expect(handleValueChange.args[0][0]).to.deep.equal(DEFAULT_VALUE - LARGE_STEP);
+ expect(input).to.have.attribute('aria-valuenow', `${DEFAULT_VALUE - LARGE_STEP}`);
+
+ fireEvent.keyDown(input!, { key: 'ArrowRight', shiftKey: true });
+ expect(handleValueChange.callCount).to.equal(2);
+ expect(handleValueChange.args[1][0]).to.deep.equal(DEFAULT_VALUE);
+ expect(input).to.have.attribute('aria-valuenow', `${DEFAULT_VALUE}`);
+ });
+
+ it('should stop at max/min when using Shift + Left Arrow / Right Arrow keys', () => {
+ const handleValueChange = spy();
+ const { container } = render(
+ ,
+ );
+
+ const input = container.querySelector('input');
+
+ fireEvent.keyDown(document.body, { key: 'TAB' });
+ act(() => {
+ (input as HTMLInputElement).focus();
+ });
+
+ fireEvent.keyDown(input!, { key: 'ArrowLeft', shiftKey: true });
+ expect(handleValueChange.callCount).to.equal(1);
+ expect(handleValueChange.args[0][0]).to.deep.equal(0);
+
+ fireEvent.keyDown(input!, { key: 'ArrowRight', shiftKey: true });
+ expect(handleValueChange.callCount).to.equal(2);
+ expect(handleValueChange.args[1][0]).to.deep.equal(8);
+ });
+
+ it('can be removed from the tab sequence', () => {
+ render();
+ expect(screen.getByRole('slider')).to.have.property('tabIndex', -1);
+ });
+ });
+});
diff --git a/packages/mui-base/src/Slider/Root/SliderRoot.tsx b/packages/mui-base/src/Slider/Root/SliderRoot.tsx
new file mode 100644
index 0000000000..1d710a3284
--- /dev/null
+++ b/packages/mui-base/src/Slider/Root/SliderRoot.tsx
@@ -0,0 +1,195 @@
+'use client';
+import * as React from 'react';
+import PropTypes from 'prop-types';
+import { useComponentRenderer } from '../../utils/useComponentRenderer';
+import { sliderStyleHookMapping } from './styleHooks';
+import { useSliderRoot } from './useSliderRoot';
+import { SliderProvider } from './SliderProvider';
+import { SliderRootProps, SliderRootOwnerState } from './SliderRoot.types';
+
+const SliderRoot = React.forwardRef(function SliderRoot(
+ props: SliderRootProps,
+ forwardedRef: React.ForwardedRef,
+) {
+ const {
+ 'aria-labelledby': ariaLabelledby,
+ className,
+ defaultValue,
+ direction = 'ltr',
+ disabled = false,
+ largeStep,
+ render,
+ minStepsBetweenValues,
+ onValueChange,
+ onValueCommitted,
+ orientation = 'horizontal',
+ value,
+ ...otherProps
+ } = props;
+
+ const { getRootProps, ...slider } = useSliderRoot({
+ 'aria-labelledby': ariaLabelledby,
+ defaultValue,
+ disabled,
+ direction,
+ largeStep,
+ minStepsBetweenValues,
+ onValueChange,
+ onValueCommitted,
+ orientation,
+ rootRef: forwardedRef,
+ value,
+ ...otherProps,
+ });
+
+ const ownerState: SliderRootOwnerState = React.useMemo(
+ () => ({
+ activeThumbIndex: slider.active,
+ direction,
+ disabled,
+ dragging: slider.dragging,
+ orientation,
+ max: slider.max,
+ min: slider.min,
+ minStepsBetweenValues: slider.minStepsBetweenValues,
+ step: slider.step,
+ values: slider.values,
+ }),
+ [
+ direction,
+ disabled,
+ orientation,
+ slider.active,
+ slider.dragging,
+ slider.max,
+ slider.min,
+ slider.minStepsBetweenValues,
+ slider.step,
+ slider.values,
+ ],
+ );
+
+ const contextValue = React.useMemo(
+ () => ({
+ ...slider,
+ ownerState,
+ }),
+ [slider, ownerState],
+ );
+
+ const { renderElement } = useComponentRenderer({
+ propGetter: getRootProps,
+ render: render ?? 'div',
+ ownerState,
+ className,
+ extraProps: otherProps,
+ customStyleHookMapping: sliderStyleHookMapping,
+ });
+
+ return {renderElement()};
+});
+
+SliderRoot.propTypes /* remove-proptypes */ = {
+ // ┌────────────────────────────── Warning ──────────────────────────────┐
+ // │ These PropTypes are generated from the TypeScript type definitions. │
+ // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │
+ // └─────────────────────────────────────────────────────────────────────┘
+ /**
+ * The id of the element containing a label for the slider.
+ */
+ 'aria-labelledby': PropTypes.string,
+ /**
+ * @ignore
+ */
+ children: PropTypes.node,
+ /**
+ * Class names applied to the element or a function that returns them based on the component's state.
+ */
+ className: PropTypes.oneOfType([PropTypes.func, PropTypes.string]),
+ /**
+ * The default value of the slider. Use when the component is not controlled.
+ */
+ defaultValue: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.number), PropTypes.number]),
+ /**
+ * Sets the direction. For right-to-left languages, the lowest value is on the right-hand side.
+ * @default 'ltr'
+ */
+ direction: PropTypes.oneOf(['ltr', 'rtl']),
+ /**
+ * /**
+ * If `true`, the component is disabled.
+ * @default false
+ */
+ disabled: PropTypes.bool,
+ /**
+ * The granularity with which the slider can step through values when using Page Up/Page Down or Shift + Arrow Up/Arrow Down.
+ * @default 10
+ */
+ largeStep: PropTypes.number,
+ /**
+ * The maximum allowed value of the slider.
+ * Should not be equal to min.
+ * @default 100
+ */
+ max: PropTypes.number,
+ /**
+ * The minimum allowed value of the slider.
+ * Should not be equal to max.
+ * @default 0
+ */
+ min: PropTypes.number,
+ /**
+ * The minimum steps between values in a range slider.
+ * @default 0
+ */
+ minStepsBetweenValues: PropTypes.number,
+ /**
+ * Name attribute of the hidden `input` element.
+ */
+ name: PropTypes.string,
+ /**
+ * Callback function that is fired when the slider's value changed.
+ *
+ * @param {number | number[]} value The new value.
+ * @param {number} activeThumb Index of the currently moved thumb.
+ * @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.
+ */
+ onValueChange: PropTypes.func,
+ /**
+ * Callback function that is fired when the `pointerup` is triggered.
+ *
+ * @param {number | number[]} value The new value.
+ * @param {Event} event The event source of the callback.
+ * **Warning**: This is a generic event not a change event.
+ */
+ onValueCommitted: PropTypes.func,
+ /**
+ * The component orientation.
+ * @default 'horizontal'
+ */
+ orientation: PropTypes.oneOf(['horizontal', 'vertical']),
+ /**
+ * A function to customize rendering of the component.
+ */
+ render: PropTypes.oneOfType([PropTypes.element, PropTypes.func]),
+ /**
+ * 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.
+ * @default 1
+ */
+ step: PropTypes.number,
+ /**
+ * Tab index attribute of the Thumb component's `input` element.
+ */
+ tabIndex: PropTypes.number,
+ /**
+ * The value of the slider.
+ * For ranged sliders, provide an array with two values.
+ */
+ value: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.number), PropTypes.number]),
+} as any;
+
+export { SliderRoot };
diff --git a/packages/mui-base/src/Slider/Root/SliderRoot.types.ts b/packages/mui-base/src/Slider/Root/SliderRoot.types.ts
new file mode 100644
index 0000000000..fe3b53d9b9
--- /dev/null
+++ b/packages/mui-base/src/Slider/Root/SliderRoot.types.ts
@@ -0,0 +1,282 @@
+import type { BaseUIComponentProps } from '../../utils/types';
+import { CompoundComponentContextValue } from '../../useCompound';
+
+export interface SliderThumbMetadata {
+ inputId: string;
+ ref: React.RefObject;
+ inputRef: React.RefObject;
+}
+
+export type SliderContextValue = Omit<
+ UseSliderReturnValue,
+ 'compoundComponentContextValue' | 'getRootProps'
+> & {
+ ownerState: SliderRootOwnerState;
+};
+
+export type SliderProviderValue = SliderContextValue & {
+ compoundComponentContextValue: CompoundComponentContextValue;
+};
+
+export type SliderDirection = 'ltr' | 'rtl';
+
+export type SliderOrientation = 'horizontal' | 'vertical';
+
+export interface SliderRootOwnerState {
+ /**
+ * The index of the active thumb.
+ */
+ activeThumbIndex: number;
+ /**
+ * If `true`, the component is disabled.
+ */
+ disabled: boolean;
+ /**
+ * If `true`, a thumb is being dragged by a pointer.
+ */
+ dragging: boolean;
+ direction: SliderDirection;
+ max: number;
+ min: number;
+ /**
+ * The minimum steps between values in a range slider.
+ * @default 0
+ */
+ minStepsBetweenValues: number;
+ /**
+ * The component orientation.
+ */
+ orientation: SliderOrientation;
+ /**
+ * The step increment of the slider when incrementing or decrementing. It will snap
+ * to multiples of this value. Decimal values are supported.
+ * @default 1
+ */
+ step: number;
+ /**
+ * The raw number value of the slider.
+ */
+ values: ReadonlyArray;
+}
+
+export interface SliderRootProps
+ extends Omit,
+ Omit<
+ BaseUIComponentProps<'span', SliderRootOwnerState>,
+ 'defaultValue' | 'onChange' | 'values'
+ > {
+ /**
+ * The default value of the slider. Use when the component is not controlled.
+ */
+ defaultValue?: number | ReadonlyArray;
+ /**
+ /**
+ * If `true`, the component is disabled.
+ * @default false
+ */
+ disabled?: boolean;
+ /**
+ * The value of the slider.
+ * For ranged sliders, provide an array with two values.
+ */
+ value?: number | ReadonlyArray;
+}
+
+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;
+ /**
+ * Sets the direction. For right-to-left languages, the lowest value is on the right-hand side.
+ * @default 'ltr'
+ */
+ direction?: SliderDirection;
+ /**
+ * If `true`, the component is disabled.
+ * @default false
+ */
+ disabled?: boolean;
+ /**
+ * 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;
+ /**
+ * The minimum steps between values in a range slider.
+ * @default 0
+ */
+ minStepsBetweenValues?: number;
+ /**
+ * Name attribute of the hidden `input` element.
+ */
+ name?: string;
+ /**
+ * Callback function that is fired when the slider's value changed.
+ *
+ * @param {number | number[]} value The new value.
+ * @param {number} activeThumb Index of the currently moved thumb.
+ * @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.
+ */
+ onValueChange?: (value: number | number[], activeThumb: number, event: Event) => void;
+ /**
+ * Callback function that is fired when the `pointerup` is triggered.
+ *
+ * @param {number | number[]} value The new value.
+ * @param {Event} event The event source of the callback.
+ * **Warning**: This is a generic event not a change event.
+ */
+ onValueCommitted?: (value: number | number[], event: Event) => void;
+ /**
+ * The component orientation.
+ * @default 'horizontal'
+ */
+ orientation?: SliderOrientation;
+ /**
+ * The ref attached to the root of the Slider.
+ */
+ rootRef?: React.Ref;
+ /**
+ * The granularity with which the slider can step through values when using Page Up/Page Down or Shift + Arrow Up/Arrow Down.
+ * @default 10
+ */
+ largeStep?: 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.
+ * @default 1
+ */
+ step?: number;
+ /**
+ * Tab index attribute of the Thumb component's `input` element.
+ */
+ tabIndex?: number;
+ /**
+ * The value of the slider.
+ * For ranged sliders, provide an array with two values.
+ */
+ value?: number | ReadonlyArray;
+}
+
+export type Axis = SliderOrientation | '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 {
+ getRootProps: (
+ externalProps?: React.ComponentPropsWithRef<'span'>,
+ ) => React.ComponentPropsWithRef<'span'>;
+ /**
+ * The index of the active thumb.
+ */
+ active: number;
+ /**
+ * A function that compares a new value with the internal value of the slider.
+ * The internal value is potentially unsorted, e.g. to support frozen arrays: https://github.com/mui/material-ui/pull/28472
+ */
+ areValuesEqual: (newValue: number | ReadonlyArray) => boolean;
+ 'aria-labelledby'?: string;
+ /**
+ * The orientation of the slider.
+ */
+ axis: Axis;
+ changeValue: (
+ valueInput: number,
+ index: number,
+ event: React.KeyboardEvent | React.ChangeEvent,
+ ) => void;
+ compoundComponentContextValue: CompoundComponentContextValue;
+ dragging: boolean;
+ direction: SliderDirection;
+ disabled: boolean;
+ getFingerNewValue: (args: {
+ finger: { x: number; y: number };
+ move?: boolean;
+ offset?: number;
+ activeIndex?: number;
+ }) => { newValue: number | number[]; activeIndex: number; newPercentageValue: number } | null;
+ handleValueChange: (
+ value: number | number[],
+ activeThumb: number,
+ event: React.SyntheticEvent | Event,
+ ) => void;
+ /**
+ * The large step value of the slider when incrementing or decrementing while the shift key is held,
+ * or when using Page-Up or Page-Down keys. Snaps to multiples of this value.
+ * @default 10
+ */
+ largeStep: number;
+ /**
+ * The maximum allowed value of the slider.
+ */
+ max: number;
+ /**
+ * The minimum allowed value of the slider.
+ */
+ min: number;
+ /**
+ * The minimum steps between values in a range slider.
+ */
+ minStepsBetweenValues: number;
+ name?: string;
+ onValueCommitted?: (value: number | number[], event: Event) => void;
+ /**
+ * The component orientation.
+ * @default 'horizontal'
+ */
+ orientation: SliderOrientation;
+ registerSliderControl: (element: HTMLElement | null) => void;
+ /**
+ * The value(s) of the slider as percentages
+ */
+ percentageValues: readonly number[];
+ setActive: (activeIndex: number) => void;
+ setDragging: (isDragging: boolean) => void;
+ setValueState: (newValue: number | number[]) => void;
+ /**
+ * The step increment of the slider when incrementing or decrementing. It will snap
+ * to multiples of this value. Decimal values are supported.
+ * @default 1
+ */
+ step: number;
+ /**
+ * A map containing all the Thumb components registered to the slider
+ */
+ subitems: Map;
+ tabIndex?: number;
+ /**
+ * The value(s) of the slider
+ */
+ values: readonly number[];
+}
diff --git a/packages/mui-base/src/Slider/Root/styleHooks.ts b/packages/mui-base/src/Slider/Root/styleHooks.ts
new file mode 100644
index 0000000000..e90a3d172c
--- /dev/null
+++ b/packages/mui-base/src/Slider/Root/styleHooks.ts
@@ -0,0 +1,12 @@
+import type { CustomStyleHookMapping } from '../../utils/getStyleHookProps';
+import type { SliderRootOwnerState } from './SliderRoot.types';
+
+export const sliderStyleHookMapping: CustomStyleHookMapping = {
+ activeThumbIndex: () => null,
+ direction: () => null,
+ max: () => null,
+ min: () => null,
+ minStepsBetweenValues: () => null,
+ step: () => null,
+ values: () => null,
+};
diff --git a/packages/mui-base/src/Slider/Root/useSliderRoot.ts b/packages/mui-base/src/Slider/Root/useSliderRoot.ts
new file mode 100644
index 0000000000..3430d1e827
--- /dev/null
+++ b/packages/mui-base/src/Slider/Root/useSliderRoot.ts
@@ -0,0 +1,444 @@
+'use client';
+import * as React from 'react';
+import { areArraysEqual } from '../../utils/areArraysEqual';
+import { clamp } from '../../utils/clamp';
+import { mergeReactProps } from '../../utils/mergeReactProps';
+import { ownerDocument } from '../../utils/owner';
+import { useControlled } from '../../utils/useControlled';
+import { useForkRef } from '../../utils/useForkRef';
+import { useCompoundParent } from '../../useCompound';
+import { useEnhancedEffect } from '../../utils/useEnhancedEffect';
+import { percentToValue, roundValueToStep, valueToPercent } from '../utils';
+import { SliderThumbMetadata, UseSliderParameters, UseSliderReturnValue } from './SliderRoot.types';
+
+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;
+}
+
+export 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 setValueIndex({
+ values,
+ newValue,
+ index,
+}: {
+ values: number[];
+ newValue: number;
+ index: number;
+}) {
+ const output = values.slice();
+ output[index] = newValue;
+ return output.sort(asc);
+}
+
+export function validateMinimumDistance(
+ values: number | readonly number[],
+ step: number,
+ minStepsBetweenValues: number,
+) {
+ if (!Array.isArray(values)) {
+ return true;
+ }
+
+ const distances = values.reduce((acc: number[], val, index, vals) => {
+ if (index === vals.length - 1) {
+ return acc;
+ }
+
+ acc.push(Math.abs(val - vals[index + 1]));
+
+ return acc;
+ }, []);
+
+ return Math.min(...distances) >= step * minStepsBetweenValues;
+}
+
+export function trackFinger(
+ event: TouchEvent | PointerEvent | React.PointerEvent,
+ touchIdRef: React.RefObject,
+) {
+ // The event is TouchEvent
+ if (touchIdRef.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 === touchIdRef.current) {
+ return {
+ x: touch.clientX,
+ y: touch.clientY,
+ };
+ }
+ }
+
+ return false;
+ }
+
+ // The event is PointerEvent
+ return {
+ x: (event as PointerEvent).clientX,
+ y: (event as PointerEvent).clientY,
+ };
+}
+
+/**
+ *
+ * Demos:
+ *
+ * - [Slider](https://mui.com/base-ui/react-slider/#hooks)
+ *
+ * API:
+ *
+ * - [useSliderRoot API](https://mui.com/base-ui/react-slider/hooks-api/#use-slider-root)
+ */
+function useSliderRoot(parameters: UseSliderParameters): UseSliderReturnValue {
+ const {
+ 'aria-labelledby': ariaLabelledby,
+ defaultValue,
+ direction = 'ltr',
+ disabled = false,
+ largeStep = 10,
+ max = 100,
+ min = 0,
+ minStepsBetweenValues = 0,
+ name,
+ onValueChange,
+ onValueCommitted,
+ orientation = 'horizontal',
+ rootRef,
+ step = 1,
+ tabIndex,
+ value: valueProp,
+ } = parameters;
+
+ // We can't use the :active browser pseudo-classes.
+ // - The active state isn't triggered when clicking on the rail.
+ // - The active state isn't transferred when inversing a range slider.
+ const [active, setActive] = React.useState(-1);
+
+ const [dragging, setDragging] = React.useState(false);
+
+ const controlRef: React.MutableRefObject = React.useRef(null);
+
+ const registerSliderControl = React.useCallback((element: HTMLElement | null) => {
+ if (element) {
+ controlRef.current = element;
+ }
+ }, []);
+
+ const [valueState, setValueState] = useControlled({
+ controlled: valueProp,
+ default: defaultValue ?? min,
+ name: 'Slider',
+ });
+
+ const { contextValue: compoundComponentContextValue, subitems } = useCompoundParent<
+ string,
+ SliderThumbMetadata
+ >();
+
+ const handleValueChange = React.useCallback(
+ (value: number | number[], thumbIndex: number, event: Event | React.SyntheticEvent) => {
+ if (!onValueChange) {
+ return;
+ }
+
+ // Redefine target to allow name and value to be read.
+ // This allows seamless integration with the most popular form libraries.
+ // https://github.com/mui/material-ui/issues/13485#issuecomment-676048492
+ // Clone the event to not override `target` of the original event.
+ const nativeEvent = (event as React.SyntheticEvent).nativeEvent || event;
+ // @ts-ignore The nativeEvent is function, not object
+ const clonedEvent = new nativeEvent.constructor(nativeEvent.type, nativeEvent);
+
+ Object.defineProperty(clonedEvent, 'target', {
+ writable: true,
+ value: { value, name },
+ });
+
+ onValueChange(value, thumbIndex, clonedEvent);
+ },
+ [name, onValueChange],
+ );
+
+ const range = Array.isArray(valueState);
+
+ const values = React.useMemo(() => {
+ return (range ? valueState.slice().sort(asc) : [valueState]).map((val) =>
+ val == null ? min : clamp(val, min, max),
+ );
+ }, [max, min, range, valueState]);
+
+ const sliderRef = React.useRef(null);
+
+ const handleRootRef = useForkRef(rootRef, sliderRef);
+
+ const areValuesEqual = React.useCallback(
+ (newValue: number | ReadonlyArray): boolean => {
+ if (typeof newValue === 'number' && typeof valueState === 'number') {
+ return newValue === valueState;
+ }
+ if (typeof newValue === 'object' && typeof valueState === 'object') {
+ return areArraysEqual(newValue, valueState);
+ }
+ return false;
+ },
+ [valueState],
+ );
+
+ const changeValue = React.useCallback(
+ (valueInput: number, index: number, event: React.KeyboardEvent | React.ChangeEvent) => {
+ let newValue: number | number[] = valueInput;
+
+ newValue = clamp(newValue, min, max);
+
+ if (range) {
+ // Bound the new value to the thumb's neighbours.
+ newValue = clamp(newValue, values[index - 1] || -Infinity, values[index + 1] || Infinity);
+
+ newValue = setValueIndex({
+ values,
+ newValue,
+ index,
+ });
+
+ focusThumb({ sliderRef, activeIndex: index });
+ }
+
+ if (validateMinimumDistance(newValue, step, minStepsBetweenValues)) {
+ setValueState(newValue);
+
+ if (handleValueChange && !areValuesEqual(newValue)) {
+ handleValueChange(newValue, index, event);
+ }
+
+ if (onValueCommitted) {
+ onValueCommitted(newValue, event.nativeEvent);
+ }
+ }
+ },
+ [
+ areValuesEqual,
+ handleValueChange,
+ max,
+ min,
+ minStepsBetweenValues,
+ onValueCommitted,
+ range,
+ setValueState,
+ step,
+ values,
+ ],
+ );
+
+ const isRtl = direction === 'rtl';
+
+ const previousIndexRef = React.useRef();
+ let axis = orientation;
+ if (isRtl && orientation === 'horizontal') {
+ axis += '-reverse';
+ }
+
+ const getFingerNewValue = React.useCallback(
+ ({
+ finger,
+ move = false,
+ offset = 0,
+ }: {
+ finger: { x: number; y: number };
+ // `move` is used to distinguish between when this is called by touchstart vs touchmove/end
+ move?: boolean;
+ offset?: number;
+ }) => {
+ const { current: sliderControl } = controlRef;
+ if (!sliderControl) {
+ return null;
+ }
+
+ const { width, height, bottom, left } = sliderControl!.getBoundingClientRect();
+ let percent;
+
+ if (axis.indexOf('vertical') === 0) {
+ percent = (bottom - finger.y) / height + offset;
+ } else {
+ percent = (finger.x - left) / width + offset * (isRtl ? -1 : 1);
+ }
+
+ percent = Math.min(percent, 1);
+
+ if (axis.indexOf('-reverse') !== -1) {
+ percent = 1 - percent;
+ }
+
+ let newValue;
+ newValue = percentToValue(percent, min, max);
+ if (step) {
+ newValue = roundValueToStep(newValue, step, min);
+ }
+
+ newValue = clamp(newValue, min, max);
+ let activeIndex = 0;
+
+ if (!range) {
+ return { newValue, activeIndex, newPercentageValue: percent };
+ }
+
+ if (!move) {
+ activeIndex = findClosest(values, newValue)!;
+ } else {
+ activeIndex = previousIndexRef.current!;
+ }
+
+ // Bound the new value to the thumb's neighbours.
+ newValue = clamp(
+ newValue,
+ values[activeIndex - 1] + minStepsBetweenValues || -Infinity,
+ values[activeIndex + 1] - minStepsBetweenValues || Infinity,
+ );
+
+ const previousValue = newValue;
+ newValue = setValueIndex({
+ values,
+ newValue,
+ index: activeIndex,
+ });
+
+ // Potentially swap the index if needed.
+ if (!move) {
+ activeIndex = newValue.indexOf(previousValue);
+ previousIndexRef.current = activeIndex;
+ }
+
+ return { newValue, activeIndex, newPercentageValue: percent };
+ },
+ [axis, isRtl, max, min, minStepsBetweenValues, range, step, values],
+ );
+
+ useEnhancedEffect(() => {
+ if (disabled && sliderRef.current!.contains(document.activeElement)) {
+ // This is necessary because Firefox and Safari will keep focus
+ // on a disabled element:
+ // https://codesandbox.io/p/sandbox/mui-pr-22247-forked-h151h?file=/src/App.js
+ // @ts-ignore
+ document.activeElement?.blur();
+ }
+ }, [disabled]);
+
+ if (disabled && active !== -1) {
+ setActive(-1);
+ }
+
+ const getRootProps: UseSliderReturnValue['getRootProps'] = React.useCallback(
+ (externalProps = {}) =>
+ mergeReactProps(externalProps, {
+ 'aria-labelledby': ariaLabelledby,
+ dir: direction,
+ ref: handleRootRef,
+ role: 'group',
+ }),
+ [ariaLabelledby, direction, handleRootRef],
+ );
+
+ return React.useMemo(
+ () => ({
+ getRootProps,
+ active,
+ areValuesEqual,
+ 'aria-labelledby': ariaLabelledby,
+ axis,
+ changeValue,
+ compoundComponentContextValue,
+ dragging,
+ direction,
+ disabled,
+ getFingerNewValue,
+ handleValueChange,
+ largeStep,
+ max,
+ min,
+ minStepsBetweenValues,
+ name,
+ onValueCommitted,
+ orientation,
+ percentageValues: values.map((v) => valueToPercent(v, min, max)),
+ registerSliderControl,
+ setActive,
+ setDragging,
+ setValueState,
+ step,
+ subitems,
+ tabIndex,
+ values,
+ }),
+ [
+ getRootProps,
+ active,
+ areValuesEqual,
+ ariaLabelledby,
+ axis,
+ changeValue,
+ compoundComponentContextValue,
+ dragging,
+ direction,
+ disabled,
+ getFingerNewValue,
+ handleValueChange,
+ largeStep,
+ max,
+ min,
+ minStepsBetweenValues,
+ name,
+ onValueCommitted,
+ orientation,
+ registerSliderControl,
+ setActive,
+ setDragging,
+ setValueState,
+ step,
+ subitems,
+ tabIndex,
+ values,
+ ],
+ );
+}
+
+export { useSliderRoot };
diff --git a/packages/mui-base/src/Slider/Slider.spec.tsx b/packages/mui-base/src/Slider/Slider.spec.tsx
deleted file mode 100644
index e3c4968f53..0000000000
--- a/packages/mui-base/src/Slider/Slider.spec.tsx
+++ /dev/null
@@ -1,138 +0,0 @@
-import * as React from 'react';
-import { expectType } from '@mui/types';
-import {
- Slider,
- SliderInputSlotProps,
- SliderMarkLabelSlotProps,
- SliderMarkSlotProps,
- SliderRailSlotProps,
- SliderRootSlotProps,
- SliderThumbSlotProps,
- SliderTrackSlotProps,
- SliderValueLabelSlotProps,
-} from '@base_ui/react/Slider';
-
-const Root = React.forwardRef(function Root(
- props: SliderRootSlotProps,
- ref: React.ForwardedRef,
-) {
- const { ownerState, ...other } = props;
- return ;
-});
-
-const Track = React.forwardRef(function Track(
- props: SliderTrackSlotProps,
- ref: React.ForwardedRef,
-) {
- const { ownerState, ...other } = props;
- return ;
-});
-
-const Rail = React.forwardRef(function Rail(
- props: SliderRailSlotProps,
- ref: React.ForwardedRef,
-) {
- const { ownerState, ...other } = props;
- return ;
-});
-
-const Thumb = React.forwardRef(function Thumb(
- props: SliderThumbSlotProps,
- ref: React.ForwardedRef,
-) {
- const { 'data-index': index, ownerState, ...other } = props;
- return ;
-});
-
-const Mark = React.forwardRef(function Mark(
- props: SliderMarkSlotProps,
- ref: React.ForwardedRef,
-) {
- const { 'data-index': index, ownerState, ...other } = props;
- return ;
-});
-
-const MarkLabel = React.forwardRef(function MarkLabel(
- props: SliderMarkLabelSlotProps,
- ref: React.ForwardedRef,
-) {
- const { 'data-index': index, ownerState, ...other } = props;
- return ;
-});
-
-const ValueLabel = React.forwardRef(function ValueLabel(
- props: SliderValueLabelSlotProps,
- ref: React.ForwardedRef,
-) {
- const { index, open, valueLabel, ownerState, ...other } = props;
- return ;
-});
-
-const Input = React.forwardRef(function Input(
- props: SliderInputSlotProps,
- ref: React.ForwardedRef,
-) {
- const { 'data-index': index, step, ownerState, ...other } = props;
- return ;
-});
-
-const styledSlider = (
-
-);
-
-const polymorphicComponentTest = () => {
- const CustomComponent: React.FC<{ stringProp: string; numberProp: number }> =
- function CustomComponent() {
- return ;
- };
-
- return (
-
- {/* @ts-expect-error */}
-
-
-
- slots={{
- root: 'a',
- }}
- href="#"
- />
-
-
- slots={{
- root: CustomComponent,
- }}
- stringProp="test"
- numberProp={0}
- />
- {/* @ts-expect-error */}
-
- slots={{
- root: CustomComponent,
- }}
- />
-
-
- slots={{
- root: 'button',
- }}
- onClick={(e: React.MouseEvent) => e.currentTarget.checkValidity()}
- />
-
-
- slots={{
- root: 'button',
- }}
- ref={(elem) => {
- expectType(elem);
- }}
- onMouseDown={(e) => {
- expectType, typeof e>(e);
- e.currentTarget.checkValidity();
- }}
- />
-
- );
-};
diff --git a/packages/mui-base/src/Slider/Slider.test.tsx b/packages/mui-base/src/Slider/Slider.test.tsx
deleted file mode 100644
index 99baca08e7..0000000000
--- a/packages/mui-base/src/Slider/Slider.test.tsx
+++ /dev/null
@@ -1,617 +0,0 @@
-import { expect } from 'chai';
-import * as React from 'react';
-import { spy, stub } from 'sinon';
-import { act, createRenderer, createMount, fireEvent, screen } from '@mui/internal-test-utils';
-import {
- Slider,
- sliderClasses as classes,
- SliderRootSlotProps,
- SliderValueLabelSlotProps,
-} from '@base_ui/react/Slider';
-import { describeConformanceUnstyled } from '../../test/describeConformanceUnstyled';
-
-type Touches = Array>;
-
-function createTouches(touches: Touches) {
- return {
- changedTouches: touches.map(
- (touch) =>
- new Touch({
- target: document.body,
- ...touch,
- }),
- ),
- };
-}
-
-describe('', () => {
- before(function beforeHook() {
- if (typeof Touch === 'undefined') {
- this.skip();
- }
- });
-
- const mount = createMount();
- const { render } = createRenderer();
-
- describeConformanceUnstyled(, () => ({
- classes,
- inheritComponent: 'span',
- render,
- mount,
- refInstanceof: window.HTMLSpanElement,
- testComponentPropWith: 'div',
- slots: {
- root: {
- expectedClassName: classes.root,
- },
- thumb: {
- expectedClassName: classes.thumb,
- },
- track: {
- expectedClassName: classes.track,
- },
- rail: {
- expectedClassName: classes.rail,
- },
- },
- skip: ['componentProp'],
- }));
-
- it('forwards style props on the Root component', () => {
- let ownerState = null;
- let theme = null;
-
- const Root = React.forwardRef(
- (
- {
- ownerState: ownerStateProp,
- theme: themeProp,
- ...other
- }: SliderRootSlotProps & {
- theme: any;
- },
- ref: React.ForwardedRef,
- ) => {
- ownerState = ownerStateProp;
- theme = themeProp;
- return ;
- },
- );
-
- render();
-
- expect(ownerState).not.to.equal(null);
- expect(theme).not.to.equal(null);
- });
-
- it('does not forward style props as DOM attributes if component slot is primitive', () => {
- const elementRef = React.createRef();
- render(
- ,
- );
-
- const { current: element } = elementRef;
- if (element !== null) {
- expect(element.getAttribute('ownerState')).to.equal(null);
- expect(element.getAttribute('theme')).to.equal(null);
- }
- });
-
- describe('prop: marks', () => {
- it('does not cause unknown-prop error', () => {
- const marks = [
- {
- value: 33,
- },
- ];
- expect(() => {
- render();
- }).not.to.throw();
- });
- });
-
- describe('prop: orientation', () => {
- it('sets the orientation via ARIA', () => {
- render();
-
- const slider = screen.getByRole('slider');
- expect(slider).to.have.attribute('aria-orientation', 'vertical');
- });
-
- it('does not set the orientation via appearance for WebKit browsers', function test() {
- if (/jsdom/.test(window.navigator.userAgent) || !/WebKit/.test(window.navigator.userAgent)) {
- this.skip();
- }
-
- render();
-
- const slider = screen.getByRole('slider');
-
- expect(slider).to.have.property('tagName', 'INPUT');
- expect(slider).to.have.property('type', 'range');
- // Only relevant if we implement `[role="slider"]` with `input[type="range"]`
- // We're not setting this by default because it changes horizontal keyboard navigation in WebKit: https://bugs.chromium.org/p/chromium/issues/detail?id=1162640
- expect(slider).not.toHaveComputedStyle({ webkitAppearance: 'slider-vertical' });
- });
- });
-
- it('renders a slider', () => {
- render();
-
- expect(screen.getByRole('slider')).to.have.attribute('aria-valuenow', '30');
- });
-
- type Values = Array<[string, number[]]>;
-
- const values = [
- ['readonly range', Object.freeze([2, 1])],
- ['range', [2, 1]],
- ] as Values;
- values.forEach(([valueLabel, value]) => {
- it(`calls onChange even if the ${valueLabel} did not change`, () => {
- const handleChange = spy();
-
- render(
- ,
- );
-
- const sliderRoot = screen.getByTestId('slider-root');
-
- stub(sliderRoot, 'getBoundingClientRect').callsFake(() => ({
- width: 100,
- height: 10,
- bottom: 10,
- left: 0,
- x: 0,
- y: 0,
- right: 0,
- top: 0,
- toJSON() {},
- }));
-
- // pixel: 0 20 40 60 80 100
- // slider: |---|---|---|---|---|
- // values: 0 1 2 3 4 5
- // value: ↑ ↑
- // mouse: ↑
-
- fireEvent.mouseDown(sliderRoot, {
- buttons: 1,
- clientX: 41,
- });
-
- expect(handleChange.callCount).to.equal(1);
- expect(handleChange.args[0][1]).not.to.equal(value);
- expect(handleChange.args[0][1]).to.deep.equal(value.slice().sort((a, b) => a - b));
- });
- });
-
- describe('prop: disabled', () => {
- it('should render the disabled classes', () => {
- const { container, getByRole } = render();
- expect(container.firstChild).to.have.class(classes.disabled);
- expect(getByRole('slider')).not.to.have.attribute('tabIndex');
- });
-
- it('should not respond to drag events after becoming disabled', function test() {
- // TODO: Don't skip once a fix for https://github.com/jsdom/jsdom/issues/3029 is released.
- if (/jsdom/.test(window.navigator.userAgent)) {
- this.skip();
- }
-
- const { getByRole, setProps, getByTestId } = render(
- ,
- );
-
- const sliderRoot = getByTestId('slider-root');
-
- stub(sliderRoot, 'getBoundingClientRect').callsFake(() => ({
- width: 100,
- height: 10,
- bottom: 10,
- left: 0,
- x: 0,
- y: 0,
- top: 0,
- right: 0,
- toJSON() {},
- }));
- fireEvent.touchStart(sliderRoot, createTouches([{ identifier: 1, clientX: 21, clientY: 0 }]));
-
- const thumb = getByRole('slider');
-
- expect(thumb).to.have.attribute('aria-valuenow', '21');
- expect(thumb).toHaveFocus();
-
- setProps({ disabled: true });
- expect(thumb).not.toHaveFocus();
- expect(thumb).not.to.have.class(classes.active);
-
- fireEvent.touchMove(sliderRoot, createTouches([{ identifier: 1, clientX: 30, clientY: 0 }]));
-
- expect(thumb).to.have.attribute('aria-valuenow', '21');
- });
-
- it('should not respond to drag events if disabled', function test() {
- // TODO: Don't skip once a fix for https://github.com/jsdom/jsdom/issues/3029 is released.
- if (/jsdom/.test(window.navigator.userAgent)) {
- this.skip();
- }
-
- const { getByRole, getByTestId } = render(
- ,
- );
-
- const thumb = getByRole('slider');
- const sliderRoot = getByTestId('slider-root');
-
- stub(sliderRoot, 'getBoundingClientRect').callsFake(() => ({
- width: 100,
- height: 10,
- bottom: 10,
- left: 0,
- x: 0,
- y: 0,
- top: 0,
- right: 0,
- toJSON() {},
- }));
-
- fireEvent.touchStart(sliderRoot, createTouches([{ identifier: 1, clientX: 21, clientY: 0 }]));
-
- fireEvent.touchMove(
- document.body,
- createTouches([{ identifier: 1, clientX: 30, clientY: 0 }]),
- );
-
- fireEvent.touchEnd(
- document.body,
- createTouches([{ identifier: 1, clientX: 30, clientY: 0 }]),
- );
-
- expect(thumb).to.have.attribute('aria-valuenow', '21');
- });
- });
-
- describe('marks', () => {
- it('should not render marks that are out of min&max bounds', function test() {
- if (/jsdom/.test(window.navigator.userAgent)) {
- this.skip();
- }
-
- const { container } = render(
- ,
- );
-
- expect(container.querySelectorAll(`.${classes.markLabel}`).length).to.equal(2);
- expect(container.querySelectorAll(`.${classes.mark}`).length).to.equal(2);
- expect(container.querySelectorAll(`.${classes.markLabel}`)[0].textContent).to.equal('0');
- expect(container.querySelectorAll(`.${classes.markLabel}`)[1].textContent).to.equal('100');
- });
- });
-
- describe('ARIA', () => {
- it('should have the correct aria attributes', () => {
- const { getByRole, container } = render(
- ,
- );
-
- const sliderWrapperElement = container.firstChild;
- const slider = getByRole('slider');
- const markLabels = container.querySelectorAll(`.${classes.markLabel}`);
- const input = container.querySelector('input');
- expect(slider).to.have.attribute('aria-valuemin', '0');
- expect(slider).to.have.attribute('aria-valuemax', '100');
- expect(slider).to.have.attribute('aria-valuenow', '50');
- expect(slider).to.have.attribute('aria-labelledby');
-
- expect(markLabels[0]).to.have.attribute('aria-hidden', 'true');
-
- expect(sliderWrapperElement).not.to.have.attribute('aria-labelledby');
- expect(input).to.have.attribute('aria-labelledby', 'a slider label');
- expect(input).to.have.attribute('aria-label', 'a slider');
- expect(input).to.have.attribute('aria-valuenow', '50');
- });
- });
-
- describe('slots', () => {
- it('should show the value label passed through custom value label slot', () => {
- function ValueLabel({ children }: SliderValueLabelSlotProps) {
- return {children};
- }
-
- render();
-
- expect(screen.getByTestId('value-label')).to.have.text('20');
- });
-
- it('should provide focused state to the slotProps.thumb', () => {
- const { getByTestId } = render(
- ({
- 'data-testid': `thumb-${index}`,
- 'data-focused': focused,
- 'data-active': active,
- }),
- }}
- />,
- );
-
- const firstThumb = getByTestId('thumb-0');
- const secondThumb = getByTestId('thumb-1');
-
- fireEvent.keyDown(document.body, { key: 'TAB' });
- act(() => {
- (firstThumb.firstChild as HTMLInputElement).focus();
- });
- expect(firstThumb.getAttribute('data-focused')).to.equal('true');
- expect(secondThumb.getAttribute('data-focused')).to.equal('false');
-
- act(() => {
- (secondThumb.firstChild as HTMLInputElement).focus();
- });
- expect(firstThumb.getAttribute('data-focused')).to.equal('false');
- expect(secondThumb.getAttribute('data-focused')).to.equal('true');
- });
-
- it('should provide active state to the slotProps.thumb', function test() {
- // TODO: Don't skip once a fix for https://github.com/jsdom/jsdom/issues/3029 is released.
- if (/jsdom/.test(window.navigator.userAgent)) {
- this.skip();
- }
-
- const { getByTestId } = render(
- ({
- 'data-testid': `thumb-${index}`,
- 'data-focused': focused,
- 'data-active': active,
- }),
- }}
- data-testid="slider-root"
- />,
- );
-
- const sliderRoot = getByTestId('slider-root');
-
- stub(sliderRoot, 'getBoundingClientRect').callsFake(() => ({
- width: 100,
- height: 10,
- bottom: 10,
- left: 0,
- x: 0,
- y: 0,
- top: 0,
- right: 0,
- toJSON() {},
- }));
- fireEvent.touchStart(sliderRoot, createTouches([{ identifier: 1, clientX: 21, clientY: 0 }]));
-
- const firstThumb = getByTestId('thumb-0');
- const secondThumb = getByTestId('thumb-1');
-
- expect(firstThumb.getAttribute('data-active')).to.equal('true');
- expect(secondThumb.getAttribute('data-active')).to.equal('false');
- });
- });
-
- it('should support Shift + Left Arrow / Right Arrow keys', () => {
- const hanleChange = spy();
- const { getByTestId } = render(
- ({
- 'data-testid': `thumb-${index}`,
- 'data-focused': focused,
- 'data-active': active,
- }),
- }}
- />,
- );
-
- const thumb = getByTestId('thumb-0');
- const input = thumb.firstChild;
-
- fireEvent.keyDown(document.body, { key: 'TAB' });
- act(() => {
- (input as HTMLInputElement).focus();
- });
-
- fireEvent.keyDown(input!, { key: 'ArrowLeft', shiftKey: true });
- expect(hanleChange.callCount).to.equal(1);
- expect(hanleChange.args[0][1]).to.deep.equal(10);
-
- fireEvent.keyDown(input!, { key: 'ArrowRight', shiftKey: true });
- expect(hanleChange.callCount).to.equal(2);
- expect(hanleChange.args[1][1]).to.deep.equal(20);
- });
-
- it('should support Shift + Up Arrow / Down Arrow keys', () => {
- const hanleChange = spy();
- const { getByTestId } = render(
- ({
- 'data-testid': `thumb-${index}`,
- 'data-focused': focused,
- 'data-active': active,
- }),
- }}
- />,
- );
-
- const thumb = getByTestId('thumb-0');
- const input = thumb.firstChild;
-
- fireEvent.keyDown(document.body, { key: 'TAB' });
- act(() => {
- (input as HTMLInputElement).focus();
- });
-
- fireEvent.keyDown(input!, { key: 'ArrowDown', shiftKey: true });
- expect(hanleChange.callCount).to.equal(1);
- expect(hanleChange.args[0][1]).to.deep.equal(10);
-
- fireEvent.keyDown(input!, { key: 'ArrowUp', shiftKey: true });
- expect(hanleChange.callCount).to.equal(2);
- expect(hanleChange.args[1][1]).to.deep.equal(20);
- });
-
- it('should support PageUp / PageDown keys', () => {
- const hanleChange = spy();
- const { getByTestId } = render(
- ({
- 'data-testid': `thumb-${index}`,
- 'data-focused': focused,
- 'data-active': active,
- }),
- }}
- />,
- );
-
- const thumb = getByTestId('thumb-0');
- const input = thumb.firstChild;
-
- fireEvent.keyDown(document.body, { key: 'TAB' });
- act(() => {
- (input as HTMLInputElement).focus();
- });
-
- fireEvent.keyDown(input!, { key: 'PageDown' });
- expect(hanleChange.callCount).to.equal(1);
- expect(hanleChange.args[0][1]).to.deep.equal(10);
-
- fireEvent.keyDown(input!, { key: 'PageUp' });
- expect(hanleChange.callCount).to.equal(2);
- expect(hanleChange.args[1][1]).to.deep.equal(20);
- });
-
- it('should support Shift + Left Arrow / Right Arrow keys by taking acount step and shiftStep', () => {
- const hanleChange = spy();
- const defaultValue = 20;
- const shiftStep = 15;
- const step = 5;
- const { getByTestId } = render(
- ({
- 'data-testid': `thumb-${index}`,
- 'data-focused': focused,
- 'data-active': active,
- }),
- }}
- />,
- );
-
- const thumb = getByTestId('thumb-0');
- const input = thumb.firstChild;
-
- fireEvent.keyDown(document.body, { key: 'TAB' });
- act(() => {
- (input as HTMLInputElement).focus();
- });
-
- fireEvent.keyDown(input!, { key: 'ArrowLeft', shiftKey: true });
- expect(hanleChange.callCount).to.equal(1);
- expect(hanleChange.args[0][1]).to.deep.equal(defaultValue - shiftStep);
- expect(input).to.have.attribute('aria-valuenow', `${defaultValue - shiftStep}`);
-
- fireEvent.keyDown(input!, { key: 'ArrowRight', shiftKey: true });
- expect(hanleChange.callCount).to.equal(2);
- expect(hanleChange.args[1][1]).to.deep.equal(defaultValue);
- expect(input).to.have.attribute('aria-valuenow', `${defaultValue}`);
- });
-
- it('should stop at max/min when using Shift + Left Arrow / Right Arrow keys', () => {
- const hanleChange = spy();
- const { getByTestId } = render(
- ({
- 'data-testid': `thumb-${index}`,
- 'data-focused': focused,
- 'data-active': active,
- }),
- }}
- />,
- );
-
- const thumb = getByTestId('thumb-0');
- const input = thumb.firstChild;
-
- fireEvent.keyDown(document.body, { key: 'TAB' });
- act(() => {
- (input as HTMLInputElement).focus();
- });
-
- fireEvent.keyDown(input!, { key: 'ArrowLeft', shiftKey: true });
- expect(hanleChange.callCount).to.equal(1);
- expect(hanleChange.args[0][1]).to.deep.equal(0);
-
- fireEvent.keyDown(input!, { key: 'ArrowRight', shiftKey: true });
- expect(hanleChange.callCount).to.equal(2);
- expect(hanleChange.args[1][1]).to.deep.equal(8);
- });
-});
diff --git a/packages/mui-base/src/Slider/Slider.tsx b/packages/mui-base/src/Slider/Slider.tsx
deleted file mode 100644
index c3b7c888df..0000000000
--- a/packages/mui-base/src/Slider/Slider.tsx
+++ /dev/null
@@ -1,538 +0,0 @@
-'use client';
-import * as React from 'react';
-import PropTypes from 'prop-types';
-import clsx from 'clsx';
-import { chainPropTypes } from '@mui/utils';
-import { PolymorphicComponent } from '../legacy/utils/PolymorphicComponent';
-import { isHostComponent } from '../utils/isHostComponent';
-import { unstable_composeClasses as composeClasses } from '../legacy/composeClasses';
-import { getSliderUtilityClass } from './sliderClasses';
-import { useSlider, valueToPercent } from '../useSlider';
-import { useSlotProps } from '../legacy/utils/useSlotProps';
-import { resolveComponentProps } from '../legacy/utils/resolveComponentProps';
-import { SliderOwnerState, SliderProps, SliderTypeMap } from './Slider.types';
-import { useClassNamesOverride } from '../legacy/utils/ClassNameConfigurator';
-
-// @ts-ignore
-function Identity(x) {
- return x;
-}
-
-const useUtilityClasses = (ownerState: SliderOwnerState) => {
- const { disabled, dragging, marked, orientation, track } = ownerState;
-
- const slots = {
- root: [
- 'root',
- disabled && 'disabled',
- dragging && 'dragging',
- marked && 'marked',
- orientation === 'vertical' && 'vertical',
- track === 'inverted' && 'trackInverted',
- track === false && 'trackFalse',
- ],
- rail: ['rail'],
- track: ['track'],
- mark: ['mark'],
- markActive: ['markActive'],
- markLabel: ['markLabel'],
- markLabelActive: ['markLabelActive'],
- valueLabel: ['valueLabel'],
- thumb: ['thumb', disabled && 'disabled'],
- active: ['active'],
- disabled: ['disabled'],
- focusVisible: ['focusVisible'],
- };
-
- return composeClasses(slots, useClassNamesOverride(getSliderUtilityClass));
-};
-
-/**
- *
- * Demos:
- *
- * - [Slider](https://mui.com/base-ui/react-slider/)
- *
- * API:
- *
- * - [Slider API](https://mui.com/base-ui/react-slider/components-api/#slider)
- */
-const Slider = React.forwardRef(function Slider(
- props: SliderProps,
- forwardedRef: React.ForwardedRef,
-) {
- const {
- 'aria-label': ariaLabel,
- 'aria-valuetext': ariaValuetext,
- 'aria-labelledby': ariaLabelledby,
- className,
- disableSwap = false,
- disabled = false,
- getAriaLabel,
- getAriaValueText,
- marks: marksProp = false,
- max = 100,
- min = 0,
- name,
- onChange,
- onChangeCommitted,
- orientation = 'horizontal',
- shiftStep = 10,
- scale = Identity,
- step = 1,
- tabIndex,
- track = 'normal',
- value: valueProp,
- valueLabelFormat = Identity,
- isRtl = false,
- defaultValue,
- slotProps = {},
- slots = {},
- ...other
- } = props;
-
- // all props with defaults
- // consider extracting to hook an reusing the lint rule for the variants
- const partialOwnerState: Omit<
- SliderOwnerState,
- 'focusedThumbIndex' | 'activeThumbIndex' | 'marked' | 'dragging'
- > = {
- ...props,
- marks: marksProp,
- disabled,
- disableSwap,
- isRtl,
- defaultValue,
- max,
- min,
- orientation,
- scale,
- step,
- shiftStep,
- track,
- valueLabelFormat,
- };
-
- const {
- axisProps,
- getRootProps,
- getHiddenInputProps,
- getThumbProps,
- active,
- axis,
- range,
- focusedThumbIndex,
- dragging,
- marks,
- values,
- trackOffset,
- trackLeap,
- getThumbStyle,
- } = useSlider({ ...partialOwnerState, rootRef: forwardedRef });
-
- const ownerState: SliderOwnerState = {
- ...partialOwnerState,
- marked: marks.length > 0 && marks.some((mark) => mark.label),
- dragging,
- focusedThumbIndex,
- activeThumbIndex: active,
- };
-
- const classes = useUtilityClasses(ownerState);
-
- const Root = slots.root ?? 'span';
- const rootProps = useSlotProps({
- elementType: Root,
- getSlotProps: getRootProps,
- externalSlotProps: slotProps.root,
- externalForwardedProps: other,
- ownerState,
- className: [classes.root, className],
- });
-
- const Rail = slots.rail ?? 'span';
- const railProps = useSlotProps({
- elementType: Rail,
- externalSlotProps: slotProps.rail,
- ownerState,
- className: classes.rail,
- });
-
- const Track = slots.track ?? 'span';
- const trackProps = useSlotProps({
- elementType: Track,
- externalSlotProps: slotProps.track,
- additionalProps: {
- style: {
- ...axisProps[axis].offset(trackOffset),
- ...axisProps[axis].leap(trackLeap),
- },
- },
- ownerState,
- className: classes.track,
- });
-
- const Thumb = slots.thumb ?? 'span';
- const thumbProps = useSlotProps({
- elementType: Thumb,
- getSlotProps: getThumbProps,
- externalSlotProps: slotProps.thumb,
- ownerState,
- skipResolvingSlotProps: true,
- });
-
- const ValueLabel = slots.valueLabel;
- const valueLabelProps = useSlotProps({
- elementType: ValueLabel,
- externalSlotProps: slotProps.valueLabel,
- ownerState,
- });
-
- const Mark = slots.mark ?? 'span';
- const markProps = useSlotProps({
- elementType: Mark,
- externalSlotProps: slotProps.mark,
- ownerState,
- className: classes.mark,
- });
-
- const MarkLabel = slots.markLabel ?? 'span';
- const markLabelProps = useSlotProps({
- elementType: MarkLabel,
- externalSlotProps: slotProps.markLabel,
- ownerState,
- });
-
- const Input = slots.input || 'input';
- const inputProps = useSlotProps({
- elementType: Input,
- getSlotProps: getHiddenInputProps,
- externalSlotProps: slotProps.input,
- ownerState,
- });
-
- 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}
-
- ) : null}
-
- );
- })}
- {values.map((value, index) => {
- const percent = valueToPercent(value, min, max);
- const style = axisProps[axis].offset(percent);
- const resolvedSlotProps = resolveComponentProps(slotProps.thumb, ownerState, {
- index,
- focused: focusedThumbIndex === index,
- active: active === index,
- });
- return (
-
-
- {ValueLabel ? (
-
- {typeof valueLabelFormat === 'function'
- ? valueLabelFormat(scale(value), index)
- : valueLabelFormat}
-
- ) : null}
-
- );
- })}
-
- );
-}) as PolymorphicComponent;
-
-Slider.propTypes /* remove-proptypes */ = {
- // ┌────────────────────────────── Warning ──────────────────────────────┐
- // │ These PropTypes are generated from the TypeScript type definitions. │
- // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │
- // └─────────────────────────────────────────────────────────────────────┘
- /**
- * The label of the slider.
- */
- 'aria-label': chainPropTypes(PropTypes.string, (props) => {
- const range = Array.isArray(props.value || props.defaultValue);
-
- if (range && props['aria-label'] != null) {
- return new Error(
- 'MUI: You need to use the `getAriaLabel` prop instead of `aria-label` when using a range slider.',
- );
- }
-
- return null;
- }),
- /**
- * The id of the element containing a label for the slider.
- */
- 'aria-labelledby': PropTypes.string,
- /**
- * A string value that provides a user-friendly name for the current value of the slider.
- */
- 'aria-valuetext': chainPropTypes(PropTypes.string, (props) => {
- const range = Array.isArray(props.value || props.defaultValue);
-
- if (range && props['aria-valuetext'] != null) {
- return new Error(
- 'MUI: You need to use the `getAriaValueText` prop instead of `aria-valuetext` when using a range slider.',
- );
- }
-
- return null;
- }),
- /**
- * The default value. Use when the component is not controlled.
- */
- defaultValue: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.number), PropTypes.number]),
- /**
- * If `true`, the component is disabled.
- * @default false
- */
- disabled: PropTypes.bool,
- /**
- * If `true`, the active thumb doesn't swap when moving pointer over a thumb while dragging another thumb.
- * @default false
- */
- disableSwap: PropTypes.bool,
- /**
- * 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: PropTypes.func,
- /**
- * 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: PropTypes.func,
- /**
- * If `true` the Slider will be rendered right-to-left (with the lowest value on the right-hand side).
- * @default false
- */
- isRtl: PropTypes.bool,
- /**
- * 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: PropTypes.oneOfType([
- PropTypes.arrayOf(
- PropTypes.shape({
- label: PropTypes.node,
- value: PropTypes.number.isRequired,
- }),
- ),
- PropTypes.bool,
- ]),
- /**
- * The maximum allowed value of the slider.
- * Should not be equal to min.
- * @default 100
- */
- max: PropTypes.number,
- /**
- * The minimum allowed value of the slider.
- * Should not be equal to max.
- * @default 0
- */
- min: PropTypes.number,
- /**
- * Name attribute of the hidden `input` element.
- */
- name: PropTypes.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: PropTypes.func,
- /**
- * 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: PropTypes.func,
- /**
- * The component orientation.
- * @default 'horizontal'
- */
- orientation: PropTypes.oneOf(['horizontal', 'vertical']),
- /**
- * A transformation function, to change the scale of the slider.
- * @param {any} x
- * @returns {any}
- * @default function Identity(x) {
- * return x;
- * }
- */
- scale: PropTypes.func,
- /**
- * 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: PropTypes.number,
- /**
- * The props used for each slot inside the Slider.
- * @default {}
- */
- slotProps: PropTypes.shape({
- input: PropTypes.oneOfType([PropTypes.func, PropTypes.object]),
- mark: PropTypes.oneOfType([PropTypes.func, PropTypes.object]),
- markLabel: PropTypes.oneOfType([PropTypes.func, PropTypes.object]),
- rail: PropTypes.oneOfType([PropTypes.func, PropTypes.object]),
- root: PropTypes.oneOfType([PropTypes.func, PropTypes.object]),
- thumb: PropTypes.oneOfType([PropTypes.func, PropTypes.object]),
- track: PropTypes.oneOfType([PropTypes.func, PropTypes.object]),
- valueLabel: PropTypes.oneOfType([PropTypes.any, PropTypes.func]),
- }),
- /**
- * The components used for each slot inside the Slider.
- * Either a string to use a HTML element or a component.
- * @default {}
- */
- slots: PropTypes.shape({
- input: PropTypes.elementType,
- mark: PropTypes.elementType,
- markLabel: PropTypes.elementType,
- rail: PropTypes.elementType,
- root: PropTypes.elementType,
- thumb: PropTypes.elementType,
- track: PropTypes.elementType,
- valueLabel: PropTypes.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: PropTypes.number,
- /**
- * Tab index attribute of the hidden `input` element.
- */
- tabIndex: PropTypes.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: PropTypes.oneOf(['inverted', 'normal', false]),
- /**
- * The value of the slider.
- * For ranged sliders, provide an array with two values.
- */
- value: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.number), PropTypes.number]),
- /**
- * 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: PropTypes.oneOfType([PropTypes.func, PropTypes.string]),
-} as any;
-
-export { Slider };
diff --git a/packages/mui-base/src/Slider/Slider.types.ts b/packages/mui-base/src/Slider/Slider.types.ts
deleted file mode 100644
index a2eaa9dd75..0000000000
--- a/packages/mui-base/src/Slider/Slider.types.ts
+++ /dev/null
@@ -1,238 +0,0 @@
-import { Simplify } from '@mui/types';
-import * as React from 'react';
-import { PolymorphicProps } from '../legacy/utils/PolymorphicComponent';
-import { SlotComponentProps, SlotComponentPropsWithSlotState } from '../legacy/utils/types';
-import {
- UseSliderHiddenInputProps,
- UseSliderParameters,
- UseSliderRootSlotProps,
- UseSliderThumbSlotProps,
-} from '../useSlider';
-
-export type SliderOwnerState = Simplify<
- SliderOwnProps & {
- disabled: boolean;
- focusedThumbIndex: number;
- activeThumbIndex: number;
- isRtl: boolean;
- max: number;
- min: number;
- dragging: boolean;
- marked: boolean;
- orientation: 'horizontal' | 'vertical';
- scale: (value: number) => number;
- step: number | null;
- track: 'normal' | false | 'inverted';
- valueLabelFormat: string | ((value: number, index: number) => React.ReactNode);
- }
->;
-
-export interface SliderRootSlotPropsOverrides {}
-export interface SliderTrackSlotPropsOverrides {}
-export interface SliderRailSlotPropsOverrides {}
-export interface SliderThumbSlotPropsOverrides {}
-export interface SliderMarkSlotPropsOverrides {}
-export interface SliderMarkLabelSlotPropsOverrides {}
-export interface SliderValueLabelSlotPropsOverrides {}
-export interface SliderInputSlotPropsOverrides {}
-
-export interface SliderThumbSlotState {
- focused: boolean;
- active: boolean;
- index: number;
-}
-
-export interface SliderOwnProps extends Omit {
- /**
- * The label of the slider.
- */
- 'aria-label'?: string;
- /**
- * A string value that provides a user-friendly name for the current value of the slider.
- */
- 'aria-valuetext'?: string;
- /**
- * 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;
- /**
- * The props used for each slot inside the Slider.
- * @default {}
- */
- slotProps?: {
- root?: SlotComponentProps<'span', SliderRootSlotPropsOverrides, SliderOwnerState>;
- track?: SlotComponentProps<'span', SliderTrackSlotPropsOverrides, SliderOwnerState>;
- rail?: SlotComponentProps<'span', SliderRailSlotPropsOverrides, SliderOwnerState>;
- thumb?: SlotComponentPropsWithSlotState<
- 'span',
- SliderThumbSlotPropsOverrides,
- SliderOwnerState,
- SliderThumbSlotState
- >;
- mark?: SlotComponentProps<'span', SliderMarkSlotPropsOverrides, SliderOwnerState>;
- markLabel?: SlotComponentProps<'span', SliderMarkLabelSlotPropsOverrides, SliderOwnerState>;
- valueLabel?: SlotComponentProps<
- React.ElementType,
- SliderValueLabelSlotPropsOverrides,
- SliderOwnerState
- >;
- input?: SlotComponentProps<'input', SliderInputSlotPropsOverrides, SliderOwnerState>;
- };
- /**
- * The components used for each slot inside the Slider.
- * Either a string to use a HTML element or a component.
- * @default {}
- */
- slots?: SliderSlots;
- /**
- * 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 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);
-}
-
-export interface SliderSlots {
- /**
- * The component that renders the root.
- * @default 'span'
- */
- root?: React.ElementType;
- /**
- * The component that renders the track.
- * @default 'span'
- */
- track?: React.ElementType;
- /**
- * The component that renders the rail.
- * @default 'span'
- */
- rail?: React.ElementType;
- /**
- * The component that renders the thumb.
- * @default 'span'
- */
- thumb?: React.ElementType;
- /**
- * The component that renders the mark.
- * @default 'span'
- */
- mark?: React.ElementType;
- /**
- * The component that renders the mark label.
- * @default 'span'
- */
- markLabel?: React.ElementType;
- /**
- * The component that renders the value label.
- */
- valueLabel?: React.ElementType;
- /**
- * The component that renders the input.
- * @default 'input'
- */
- input?: React.ElementType;
-}
-
-export interface SliderTypeMap<
- AdditionalProps = {},
- RootComponentType extends React.ElementType = 'span',
-> {
- props: SliderOwnProps & AdditionalProps;
- defaultComponent: RootComponentType;
-}
-
-export type SliderProps<
- RootComponentType extends React.ElementType = SliderTypeMap['defaultComponent'],
-> = PolymorphicProps, RootComponentType>;
-
-export type SliderRootSlotProps = UseSliderRootSlotProps & {
- children: React.ReactNode;
- className: string;
- ownerState: SliderOwnerState;
-};
-
-export type SliderTrackSlotProps = {
- className?: string;
- ownerState: SliderOwnerState;
- style: React.CSSProperties;
-};
-
-export type SliderRailSlotProps = {
- className?: string;
- ownerState: SliderOwnerState;
-};
-
-export type SliderThumbSlotProps = UseSliderThumbSlotProps & {
- 'data-index': number;
- children: React.ReactNode;
- className?: string;
- ownerState: SliderOwnerState;
- style: React.CSSProperties;
-};
-
-export type SliderMarkSlotProps = {
- 'data-index': number;
- className?: string;
- markActive?: boolean;
- ownerState: SliderOwnerState;
- style: React.CSSProperties;
-};
-
-export type SliderMarkLabelSlotProps = {
- 'aria-hidden': boolean;
- 'data-index': number;
- className?: string;
- markLabelActive?: boolean;
- ownerState: SliderOwnerState;
- style: React.CSSProperties;
-};
-
-export type SliderValueLabelSlotProps = {
- children: React.ReactNode;
- className?: string;
- disabled?: boolean;
- index?: number;
- open?: boolean;
- ownerState: SliderOwnerState;
- valueLabel?: string | React.ReactNode;
- valueLabelFormat?: string | ((value: number, index: number) => React.ReactNode);
-};
-
-export type SliderInputSlotProps = UseSliderHiddenInputProps & {
- 'aria-label': React.AriaAttributes['aria-label'];
- 'aria-valuenow': React.AriaAttributes['aria-valuenow'];
- 'aria-valuetext': React.AriaAttributes['aria-valuetext'];
- 'data-index': number;
- ownerState: SliderOwnerState;
- style: React.CSSProperties;
- value: number;
-};
diff --git a/packages/mui-base/src/Slider/Thumb/SliderThumb.test.tsx b/packages/mui-base/src/Slider/Thumb/SliderThumb.test.tsx
new file mode 100644
index 0000000000..5d57e3b322
--- /dev/null
+++ b/packages/mui-base/src/Slider/Thumb/SliderThumb.test.tsx
@@ -0,0 +1,72 @@
+import * as React from 'react';
+import { createRenderer } from '@mui/internal-test-utils';
+import * as Slider from '@base_ui/react/Slider';
+import { SliderProvider, type SliderProviderValue } from '@base_ui/react/Slider';
+import { describeConformance } from '../../../test/describeConformance';
+
+const NOOP = () => {};
+
+describe('', () => {
+ const { render } = createRenderer();
+
+ const testProviderValue: SliderProviderValue = {
+ active: -1,
+ areValuesEqual: () => true,
+ axis: 'horizontal',
+ changeValue: NOOP,
+ compoundComponentContextValue: {
+ registerItem: () => ({ id: 0, deregister: () => {} }),
+ getItemIndex: () => 0,
+ totalSubitemCount: 1,
+ },
+ dragging: false,
+ disabled: false,
+ getFingerNewValue: () => ({
+ newValue: 0,
+ activeIndex: 0,
+ newPercentageValue: 0,
+ }),
+ handleValueChange: NOOP,
+ direction: 'ltr',
+ largeStep: 10,
+ max: 100,
+ min: 0,
+ minStepsBetweenValues: 0,
+ orientation: 'horizontal',
+ ownerState: {
+ activeThumbIndex: -1,
+ disabled: false,
+ dragging: false,
+ direction: 'ltr',
+ max: 100,
+ min: 0,
+ minStepsBetweenValues: 0,
+ orientation: 'horizontal',
+ step: 1,
+ values: [0],
+ },
+ percentageValues: [0],
+ registerSliderControl: NOOP,
+ setActive: NOOP,
+ setDragging: NOOP,
+ setValueState: NOOP,
+ step: 1,
+ subitems: new Map(),
+ values: [0],
+ };
+
+ describeConformance(, () => ({
+ inheritComponent: 'span',
+ render: (node) => {
+ const { container, ...other } = render(
+ {node},
+ );
+
+ return { container, ...other };
+ },
+ refInstanceof: window.HTMLSpanElement,
+ skip: [
+ 'reactTestRenderer', // Need to be wrapped with SliderProvider
+ ],
+ }));
+});
diff --git a/packages/mui-base/src/Slider/Thumb/SliderThumb.tsx b/packages/mui-base/src/Slider/Thumb/SliderThumb.tsx
new file mode 100644
index 0000000000..76e1381878
--- /dev/null
+++ b/packages/mui-base/src/Slider/Thumb/SliderThumb.tsx
@@ -0,0 +1,197 @@
+'use client';
+import * as React from 'react';
+import PropTypes from 'prop-types';
+import { getStyleHookProps } from '../../utils/getStyleHookProps';
+import { mergeReactProps } from '../../utils/mergeReactProps';
+import { resolveClassName } from '../../utils/resolveClassName';
+import { useForkRef } from '../../utils/useForkRef';
+import { useSliderContext } from '../Root/SliderProvider';
+import { SliderThumbProps } from './SliderThumb.types';
+import { useSliderThumb } from './useSliderThumb';
+
+function defaultRender(
+ props: React.ComponentPropsWithRef<'span'>,
+ inputProps: React.ComponentPropsWithRef<'input'>,
+) {
+ const { children, ...thumbProps } = props;
+ return (
+
+ {children}
+
+
+ );
+}
+
+const SliderThumb = React.forwardRef(function SliderThumb(
+ props: SliderThumbProps,
+ forwardedRef: React.ForwardedRef,
+) {
+ const {
+ render: renderProp,
+ 'aria-label': ariaLabel,
+ 'aria-valuetext': ariaValuetext,
+ className,
+ disabled: disabledProp = false,
+ getAriaLabel,
+ getAriaValueText,
+ id,
+ ...otherProps
+ } = props;
+
+ const render = renderProp ?? defaultRender;
+
+ const {
+ active: activeIndex,
+ 'aria-labelledby': ariaLabelledby,
+ axis,
+ changeValue,
+ direction,
+ disabled: contextDisabled,
+ largeStep,
+ max,
+ min,
+ minStepsBetweenValues,
+ name,
+ orientation,
+ ownerState,
+ percentageValues,
+ step,
+ tabIndex,
+ values,
+ } = useSliderContext();
+
+ const mergedRef = useForkRef(typeof render === 'function' ? null : render.ref, forwardedRef);
+
+ const { getRootProps, getThumbInputProps, disabled, index } = useSliderThumb({
+ active: activeIndex,
+ 'aria-label': ariaLabel,
+ 'aria-labelledby': ariaLabelledby,
+ 'aria-valuetext': ariaValuetext,
+ axis,
+ changeValue,
+ direction,
+ disabled: disabledProp || contextDisabled,
+ getAriaLabel,
+ getAriaValueText,
+ id,
+ largeStep,
+ max,
+ min,
+ minStepsBetweenValues,
+ name,
+ orientation,
+ percentageValues,
+ rootRef: mergedRef,
+ step,
+ tabIndex,
+ values,
+ });
+
+ const styleHooks = React.useMemo(
+ () => getStyleHookProps({ disabled, dragging: index !== -1 && activeIndex === index }),
+ [activeIndex, disabled, index],
+ );
+
+ const thumbProps = getRootProps({
+ ...styleHooks,
+ ...otherProps,
+ className: resolveClassName(className, ownerState),
+ });
+
+ const inputProps = getThumbInputProps({ disabled });
+
+ if (typeof render === 'function') {
+ return render(thumbProps, inputProps, ownerState);
+ }
+
+ const { children: renderPropsChildren, ...otherRenderProps } = render.props;
+
+ const children = thumbProps.children ?? renderPropsChildren;
+
+ return React.cloneElement(
+ render,
+ mergeReactProps(otherRenderProps, {
+ ...thumbProps,
+ children: (
+
+ {typeof children === 'function' ? children() : children}
+
+
+ ),
+ }),
+ );
+});
+
+SliderThumb.propTypes /* remove-proptypes */ = {
+ // ┌────────────────────────────── Warning ──────────────────────────────┐
+ // │ These PropTypes are generated from the TypeScript type definitions. │
+ // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │
+ // └─────────────────────────────────────────────────────────────────────┘
+ /**
+ * The label for the input element.
+ */
+ 'aria-label': PropTypes.string,
+ /**
+ * A string value that provides a user-friendly name for the current value of the slider.
+ */
+ 'aria-valuetext': PropTypes.string,
+ /**
+ * @ignore
+ */
+ children: PropTypes.node,
+ /**
+ * Class names applied to the element or a function that returns them based on the component's state.
+ */
+ className: PropTypes.oneOfType([PropTypes.func, PropTypes.string]),
+ /**
+ * @ignore
+ */
+ disabled: PropTypes.bool,
+ /**
+ * Accepts a function which returns a string value that provides a user-friendly name for the input associated with the thumb
+ * @param {number} index The index of the input
+ * @returns {string}
+ */
+ getAriaLabel: PropTypes.func,
+ /**
+ * 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: PropTypes.func,
+ /**
+ * @ignore
+ */
+ id: PropTypes.string,
+ /**
+ * @ignore
+ */
+ onBlur: PropTypes.func,
+ /**
+ * @ignore
+ */
+ onFocus: PropTypes.func,
+ /**
+ * @ignore
+ */
+ onKeyDown: PropTypes.func,
+ /**
+ * @ignore
+ */
+ onPointerLeave: PropTypes.func,
+ /**
+ * @ignore
+ */
+ onPointerOver: PropTypes.func,
+ /**
+ * A function to customize rendering of the component.
+ */
+ render: PropTypes /* @typescript-to-proptypes-ignore */.oneOfType([
+ PropTypes.func,
+ PropTypes.node,
+ ]),
+} as any;
+
+export { SliderThumb };
diff --git a/packages/mui-base/src/Slider/Thumb/SliderThumb.types.ts b/packages/mui-base/src/Slider/Thumb/SliderThumb.types.ts
new file mode 100644
index 0000000000..84fe617637
--- /dev/null
+++ b/packages/mui-base/src/Slider/Thumb/SliderThumb.types.ts
@@ -0,0 +1,89 @@
+import { BaseUIComponentProps } from '../../utils/types';
+import { SliderRootOwnerState, UseSliderReturnValue } from '../Root/SliderRoot.types';
+
+export interface SliderThumbOwnerState extends SliderRootOwnerState {}
+
+export interface SliderThumbProps
+ extends Partial>,
+ Omit, 'render'> {
+ onPointerLeave?: React.PointerEventHandler;
+ onPointerOver?: React.PointerEventHandler;
+ onBlur?: React.FocusEventHandler;
+ onFocus?: React.FocusEventHandler;
+ onKeyDown?: React.KeyboardEventHandler;
+ /**
+ * A function to customize rendering of the component.
+ */
+ render?:
+ | ((
+ props: React.ComponentPropsWithRef<'span'>,
+ inputProps: React.ComponentPropsWithRef<'input'>,
+ state: SliderThumbOwnerState,
+ ) => React.ReactElement)
+ | (React.ReactElement & { ref: React.Ref });
+}
+
+export interface UseSliderThumbParameters
+ extends Pick<
+ UseSliderReturnValue,
+ | 'active'
+ | 'aria-labelledby'
+ | 'axis'
+ | 'changeValue'
+ | 'direction'
+ | 'largeStep'
+ | 'max'
+ | 'min'
+ | 'minStepsBetweenValues'
+ | 'name'
+ | 'orientation'
+ | 'percentageValues'
+ | 'step'
+ | 'tabIndex'
+ | 'values'
+ > {
+ /**
+ * The label for the input element.
+ */
+ 'aria-label'?: string;
+ /**
+ * A string value that provides a user-friendly name for the current value of the slider.
+ */
+ 'aria-valuetext'?: string;
+ /**
+ * Accepts a function which returns a string value that provides a user-friendly name for the input associated with the thumb
+ * @param {number} index The index of the input
+ * @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;
+ id?: string;
+ disabled?: boolean;
+ onBlur?: React.FocusEventHandler;
+ onFocus?: React.FocusEventHandler;
+ onKeyDown?: React.KeyboardEventHandler;
+ rootRef?: React.Ref;
+}
+
+export interface UseSliderThumbReturnValue {
+ getRootProps: (
+ externalProps?: React.ComponentPropsWithRef,
+ ) => React.ComponentPropsWithRef;
+ getThumbInputProps: (
+ externalProps?: React.ComponentPropsWithRef<'input'>,
+ ) => React.ComponentPropsWithRef<'input'>;
+ /**
+ * 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) => Record;
+ index: number;
+}
diff --git a/packages/mui-base/src/Slider/Thumb/useSliderThumb.ts b/packages/mui-base/src/Slider/Thumb/useSliderThumb.ts
new file mode 100644
index 0000000000..3f4e7b7321
--- /dev/null
+++ b/packages/mui-base/src/Slider/Thumb/useSliderThumb.ts
@@ -0,0 +1,278 @@
+import * as React from 'react';
+import { mergeReactProps } from '../../utils/mergeReactProps';
+import { useForkRef } from '../../utils/useForkRef';
+import { useId } from '../../utils/useId';
+import { visuallyHidden } from '../../utils/visuallyHidden';
+import { useCompoundItem } from '../../useCompound';
+import { SliderThumbMetadata } from '../Root/SliderRoot.types';
+import { UseSliderThumbParameters, UseSliderThumbReturnValue } from './SliderThumb.types';
+
+function idGenerator(existingKeys: Set) {
+ return `thumb-${existingKeys.size}`;
+}
+
+function getNewValue(
+ thumbValue: number,
+ step: number,
+ direction: 1 | -1,
+ min: number,
+ max: number,
+): number {
+ return direction === 1 ? Math.min(thumbValue + step, max) : Math.max(thumbValue - step, min);
+}
+
+function getDefaultAriaValueText(values: readonly number[], index: number): string | undefined {
+ if (index < 0) {
+ return undefined;
+ }
+
+ if (values.length === 2) {
+ if (index === 0) {
+ return `${values[index]} start range`;
+ }
+
+ return `${values[index]} end range`;
+ }
+
+ return undefined;
+}
+/**
+ *
+ * Demos:
+ *
+ * - [Slider](https://mui.com/base-ui/react-slider/#hooks)
+ *
+ * API:
+ *
+ * - [useSliderThumb API](https://mui.com/base-ui/react-slider/hooks-api/#use-slider-thumb)
+ */
+export function useSliderThumb(parameters: UseSliderThumbParameters) {
+ const {
+ active: activeIndex,
+ 'aria-label': ariaLabel,
+ 'aria-labelledby': ariaLabelledby,
+ 'aria-valuetext': ariaValuetext,
+ axis,
+ changeValue,
+ direction,
+ disabled,
+ getAriaLabel,
+ getAriaValueText,
+ id: idParam,
+ largeStep,
+ max,
+ min,
+ minStepsBetweenValues,
+ name,
+ orientation,
+ rootRef: externalRef,
+ step,
+ tabIndex,
+ percentageValues,
+ values: sliderValues,
+ } = parameters;
+
+ const thumbId = useId(idParam);
+ const thumbRef = React.useRef(null);
+ const inputRef = React.useRef(null);
+
+ const handleRef = useForkRef(externalRef, thumbRef);
+
+ const thumbMetadata: SliderThumbMetadata = React.useMemo(
+ () => ({ inputId: thumbId ? `${thumbId}-input` : '', ref: thumbRef, inputRef }),
+ [thumbId],
+ );
+
+ const { id: compoundItemId, index } = useCompoundItem(
+ (thumbId ? `${thumbId}-input` : thumbId) ?? idGenerator,
+ thumbMetadata,
+ );
+
+ const thumbValue = sliderValues[index];
+
+ // for SSR, don't wait for the index if there's only one thumb
+ const percent = percentageValues.length === 1 ? percentageValues[0] : percentageValues[index];
+
+ const isRtl = direction === 'rtl';
+
+ const getThumbStyle = React.useCallback(() => {
+ const isVertical = orientation === 'vertical';
+
+ if (!Number.isFinite(percent)) {
+ return visuallyHidden;
+ }
+
+ return {
+ position: 'absolute',
+ [{
+ horizontal: 'left',
+ 'horizontal-reverse': 'right',
+ vertical: 'bottom',
+ }[axis]]: `${percent}%`,
+ [isVertical ? 'left' : 'top']: '50%',
+ transform: `translate(${(isVertical || !isRtl ? -1 : 1) * 50}%, ${(isVertical ? 1 : -1) * 50}%)`,
+ // So the non active thumb doesn't show its label on hover.
+ pointerEvents: activeIndex !== -1 && activeIndex !== index ? 'none' : undefined,
+ zIndex: activeIndex === index ? 1 : undefined,
+ };
+ }, [activeIndex, axis, isRtl, orientation, percent, index]);
+
+ const getRootProps: UseSliderThumbReturnValue['getRootProps'] = React.useCallback(
+ (externalProps = {}) => {
+ return mergeReactProps(externalProps, {
+ 'data-index': index,
+ id: idParam,
+ onKeyDown(event: React.KeyboardEvent) {
+ let newValue = null;
+ const isRange = sliderValues.length > 1;
+ switch (event.key) {
+ case 'ArrowUp':
+ newValue = getNewValue(thumbValue, event.shiftKey ? largeStep : step, 1, min, max);
+ break;
+ case 'ArrowRight':
+ newValue = getNewValue(
+ thumbValue,
+ event.shiftKey ? largeStep : step,
+ isRtl ? -1 : 1,
+ min,
+ max,
+ );
+ break;
+ case 'ArrowDown':
+ newValue = getNewValue(thumbValue, event.shiftKey ? largeStep : step, -1, min, max);
+ break;
+ case 'ArrowLeft':
+ newValue = getNewValue(
+ thumbValue,
+ event.shiftKey ? largeStep : step,
+ isRtl ? 1 : -1,
+ min,
+ max,
+ );
+ break;
+ case 'PageUp':
+ newValue = getNewValue(thumbValue, largeStep, 1, min, max);
+ break;
+ case 'PageDown':
+ newValue = getNewValue(thumbValue, largeStep, -1, min, max);
+ break;
+ case 'Home':
+ newValue = max;
+
+ if (isRange) {
+ newValue = Number.isFinite(sliderValues[index + 1])
+ ? sliderValues[index + 1] - step * minStepsBetweenValues
+ : max;
+ }
+ break;
+ case 'End':
+ newValue = min;
+
+ if (isRange) {
+ newValue = Number.isFinite(sliderValues[index - 1])
+ ? sliderValues[index - 1] + step * minStepsBetweenValues
+ : min;
+ }
+ break;
+ default:
+ break;
+ }
+
+ if (newValue !== null) {
+ changeValue(newValue, index, event);
+ event.preventDefault();
+ }
+ },
+ ref: handleRef,
+ style: {
+ ...getThumbStyle(),
+ },
+ tabIndex: tabIndex ?? (disabled ? undefined : 0),
+ });
+ },
+ [
+ changeValue,
+ getThumbStyle,
+ handleRef,
+ idParam,
+ index,
+ isRtl,
+ disabled,
+ largeStep,
+ max,
+ min,
+ minStepsBetweenValues,
+ sliderValues,
+ step,
+ tabIndex,
+ thumbValue,
+ ],
+ );
+
+ const getThumbInputProps: UseSliderThumbReturnValue['getThumbInputProps'] = React.useCallback(
+ (externalProps = {}) => {
+ return mergeReactProps(externalProps, {
+ 'aria-label': getAriaLabel ? getAriaLabel(index) : ariaLabel,
+ 'aria-labelledby': ariaLabelledby,
+ 'aria-orientation': orientation,
+ 'aria-valuemax': max,
+ 'aria-valuemin': min,
+ 'aria-valuenow': thumbValue,
+ 'aria-valuetext': getAriaValueText
+ ? getAriaValueText(thumbValue, index)
+ : ariaValuetext ?? getDefaultAriaValueText(sliderValues, index),
+ 'data-index': index,
+ disabled,
+ id: compoundItemId,
+ max,
+ min,
+ name,
+ onChange(event: React.ChangeEvent) {
+ // @ts-ignore
+ changeValue(event.target.valueAsNumber, index, event);
+ },
+ ref: inputRef,
+ step,
+ style: {
+ ...visuallyHidden,
+ direction: isRtl ? 'rtl' : 'ltr',
+ // So that VoiceOver's focus indicator matches the thumb's dimensions
+ width: '100%',
+ height: '100%',
+ },
+ tabIndex: -1,
+ type: 'range',
+ value: thumbValue ?? '',
+ });
+ },
+ [
+ ariaLabel,
+ ariaLabelledby,
+ ariaValuetext,
+ changeValue,
+ compoundItemId,
+ disabled,
+ getAriaLabel,
+ getAriaValueText,
+ index,
+ isRtl,
+ max,
+ min,
+ name,
+ orientation,
+ sliderValues,
+ step,
+ thumbValue,
+ ],
+ );
+
+ return React.useMemo(
+ () => ({
+ getRootProps,
+ getThumbInputProps,
+ index,
+ disabled,
+ }),
+ [getRootProps, getThumbInputProps, index, disabled],
+ );
+}
diff --git a/packages/mui-base/src/Slider/Track/SliderTrack.test.tsx b/packages/mui-base/src/Slider/Track/SliderTrack.test.tsx
new file mode 100644
index 0000000000..07d6ca49df
--- /dev/null
+++ b/packages/mui-base/src/Slider/Track/SliderTrack.test.tsx
@@ -0,0 +1,72 @@
+import * as React from 'react';
+import { createRenderer } from '@mui/internal-test-utils';
+import * as Slider from '@base_ui/react/Slider';
+import { SliderProvider, type SliderProviderValue } from '@base_ui/react/Slider';
+import { describeConformance } from '../../../test/describeConformance';
+
+const NOOP = () => {};
+
+describe('', () => {
+ const { render } = createRenderer();
+
+ const testProviderValue: SliderProviderValue = {
+ active: -1,
+ areValuesEqual: () => true,
+ axis: 'horizontal',
+ changeValue: NOOP,
+ compoundComponentContextValue: {
+ registerItem: () => ({ id: 0, deregister: () => {} }),
+ getItemIndex: () => 0,
+ totalSubitemCount: 1,
+ },
+ dragging: false,
+ disabled: false,
+ getFingerNewValue: () => ({
+ newValue: 0,
+ activeIndex: 0,
+ newPercentageValue: 0,
+ }),
+ handleValueChange: NOOP,
+ direction: 'ltr',
+ largeStep: 10,
+ max: 100,
+ min: 0,
+ minStepsBetweenValues: 0,
+ orientation: 'horizontal',
+ ownerState: {
+ activeThumbIndex: -1,
+ disabled: false,
+ dragging: false,
+ direction: 'ltr',
+ max: 100,
+ min: 0,
+ minStepsBetweenValues: 0,
+ orientation: 'horizontal',
+ step: 1,
+ values: [0],
+ },
+ percentageValues: [0],
+ registerSliderControl: NOOP,
+ setActive: NOOP,
+ setDragging: NOOP,
+ setValueState: NOOP,
+ step: 1,
+ subitems: new Map(),
+ values: [0],
+ };
+
+ describeConformance(, () => ({
+ inheritComponent: 'span',
+ render: (node) => {
+ const { container, ...other } = render(
+ {node},
+ );
+
+ return { container, ...other };
+ },
+ refInstanceof: window.HTMLSpanElement,
+ skip: [
+ 'reactTestRenderer', // Need to be wrapped with SliderProvider
+ ],
+ }));
+});
diff --git a/packages/mui-base/src/Slider/Track/SliderTrack.tsx b/packages/mui-base/src/Slider/Track/SliderTrack.tsx
new file mode 100644
index 0000000000..969adfee2e
--- /dev/null
+++ b/packages/mui-base/src/Slider/Track/SliderTrack.tsx
@@ -0,0 +1,48 @@
+'use client';
+import * as React from 'react';
+import PropTypes from 'prop-types';
+import { useComponentRenderer } from '../../utils/useComponentRenderer';
+import { useSliderContext } from '../Root/SliderProvider';
+import { sliderStyleHookMapping } from '../Root/styleHooks';
+import { SliderTrackProps } from './SliderTrack.types';
+
+const SliderTrack = React.forwardRef(function SliderTrack(
+ props: SliderTrackProps,
+ forwardedRef: React.ForwardedRef,
+) {
+ const { render, className, ...otherProps } = props;
+
+ const { ownerState } = useSliderContext();
+
+ const { renderElement } = useComponentRenderer({
+ render: render ?? 'span',
+ ownerState,
+ className,
+ ref: forwardedRef,
+ extraProps: otherProps,
+ customStyleHookMapping: sliderStyleHookMapping,
+ });
+
+ return renderElement();
+});
+
+SliderTrack.propTypes /* remove-proptypes */ = {
+ // ┌────────────────────────────── Warning ──────────────────────────────┐
+ // │ These PropTypes are generated from the TypeScript type definitions. │
+ // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │
+ // └─────────────────────────────────────────────────────────────────────┘
+ /**
+ * @ignore
+ */
+ children: PropTypes.node,
+ /**
+ * Class names applied to the element or a function that returns them based on the component's state.
+ */
+ className: PropTypes.oneOfType([PropTypes.func, PropTypes.string]),
+ /**
+ * A function to customize rendering of the component.
+ */
+ render: PropTypes.oneOfType([PropTypes.element, PropTypes.func]),
+} as any;
+
+export { SliderTrack };
diff --git a/packages/mui-base/src/Slider/Track/SliderTrack.types.ts b/packages/mui-base/src/Slider/Track/SliderTrack.types.ts
new file mode 100644
index 0000000000..edda2fcbf6
--- /dev/null
+++ b/packages/mui-base/src/Slider/Track/SliderTrack.types.ts
@@ -0,0 +1,4 @@
+import { BaseUIComponentProps } from '../../utils/types';
+import { SliderRootOwnerState } from '../Root/SliderRoot.types';
+
+export interface SliderTrackProps extends BaseUIComponentProps<'span', SliderRootOwnerState> {}
diff --git a/packages/mui-base/src/Slider/index.barrel.ts b/packages/mui-base/src/Slider/index.barrel.ts
new file mode 100644
index 0000000000..79a640ba70
--- /dev/null
+++ b/packages/mui-base/src/Slider/index.barrel.ts
@@ -0,0 +1,40 @@
+export { SliderRoot } from './Root/SliderRoot';
+export type * from './Root/SliderRoot.types';
+export { useSliderRoot } from './Root/useSliderRoot';
+export * from './Root/SliderProvider';
+
+export { SliderOutput } from './Output/SliderOutput';
+export type {
+ SliderOutputProps as OutputProps,
+ UseSliderOutputParameters,
+ UseSliderOutputReturnValue,
+} from './Output/SliderOutput.types';
+export { useSliderOutput } from './Output/useSliderOutput';
+
+export { SliderControl } from './Control/SliderControl';
+export type {
+ SliderControlProps as ControlProps,
+ UseSliderControlParameters,
+ UseSliderControlReturnValue,
+} from './Control/SliderControl.types';
+export { useSliderControl } from './Control/useSliderControl';
+
+export { SliderTrack } from './Track/SliderTrack';
+export type { SliderTrackProps } from './Track/SliderTrack.types';
+
+export { SliderThumb } from './Thumb/SliderThumb';
+export type {
+ SliderThumbOwnerState,
+ SliderThumbProps,
+ UseSliderThumbParameters,
+ UseSliderThumbReturnValue,
+} from './Thumb/SliderThumb.types';
+export { useSliderThumb } from './Thumb/useSliderThumb';
+
+export { SliderIndicator } from './Indicator/SliderIndicator';
+export type {
+ SliderIndicatorProps as IndicatorProps,
+ UseSliderIndicatorParameters,
+ UseSliderIndicatorReturnValue,
+} from './Indicator/SliderIndicator.types';
+export { useSliderIndicator } from './Indicator/useSliderIndicator';
diff --git a/packages/mui-base/src/Slider/index.ts b/packages/mui-base/src/Slider/index.ts
index 8471da7534..32e75fcdec 100644
--- a/packages/mui-base/src/Slider/index.ts
+++ b/packages/mui-base/src/Slider/index.ts
@@ -1,4 +1,49 @@
-'use client';
-export { Slider } from './Slider';
-export * from './Slider.types';
-export * from './sliderClasses';
+export { SliderRoot as Root } from './Root/SliderRoot';
+export {
+ SliderRootOwnerState as SliderOwnerState,
+ SliderRootProps as RootProps,
+ UseSliderParameters,
+ UseSliderReturnValue,
+ SliderContextValue,
+ SliderProviderValue,
+ SliderThumbMetadata,
+ Axis,
+} from './Root/SliderRoot.types';
+export { useSliderRoot } from './Root/useSliderRoot';
+export * from './Root/SliderProvider';
+
+export { SliderOutput as Output } from './Output/SliderOutput';
+export type {
+ SliderOutputProps as OutputProps,
+ UseSliderOutputParameters,
+ UseSliderOutputReturnValue,
+} from './Output/SliderOutput.types';
+export { useSliderOutput } from './Output/useSliderOutput';
+
+export { SliderControl as Control } from './Control/SliderControl';
+export type {
+ SliderControlProps as ControlProps,
+ UseSliderControlParameters,
+ UseSliderControlReturnValue,
+} from './Control/SliderControl.types';
+export { useSliderControl } from './Control/useSliderControl';
+
+export { SliderTrack as Track } from './Track/SliderTrack';
+export type { SliderTrackProps as TrackProps } from './Track/SliderTrack.types';
+
+export { SliderThumb as Thumb } from './Thumb/SliderThumb';
+export type {
+ SliderThumbOwnerState as ThumbOwnerState,
+ SliderThumbProps as ThumbProps,
+ UseSliderThumbParameters,
+ UseSliderThumbReturnValue,
+} from './Thumb/SliderThumb.types';
+export { useSliderThumb } from './Thumb/useSliderThumb';
+
+export { SliderIndicator as Indicator } from './Indicator/SliderIndicator';
+export type {
+ SliderIndicatorProps as IndicatorProps,
+ UseSliderIndicatorParameters,
+ UseSliderIndicatorReturnValue,
+} from './Indicator/SliderIndicator.types';
+export { useSliderIndicator } from './Indicator/useSliderIndicator';
diff --git a/packages/mui-base/src/Slider/sliderClasses.ts b/packages/mui-base/src/Slider/sliderClasses.ts
deleted file mode 100644
index 1b449aa1d8..0000000000
--- a/packages/mui-base/src/Slider/sliderClasses.ts
+++ /dev/null
@@ -1,64 +0,0 @@
-import { generateUtilityClasses } from '../legacy/generateUtilityClasses';
-import { generateUtilityClass } from '../legacy/generateUtilityClass';
-
-const COMPONENT_NAME = 'Slider';
-
-export interface SliderClasses {
- /** Class name applied to the root element. */
- root: string;
- /** Class name applied to the root element if `marks` is provided with at least one label. */
- marked: string;
- /** Class name 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;
- /** Class name applied to the rail element. */
- rail: string;
- /** Class name applied to the track element. */
- track: string;
- /** Class name applied to the root element if `track={false}`. */
- trackFalse: string;
- /** Class name applied to the root element if `track="inverted"`. */
- trackInverted: string;
- /** Class name 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;
- /** Class name applied to the mark element. */
- mark: string;
- /** Class name applied to the mark element if active (depending on the value). */
- markActive: string;
- /** Class name applied to the mark label element. */
- markLabel: string;
- /** Class name applied to the mark label element if active (depending on the value). */
- markLabelActive: string;
-}
-
-export type SliderClassKey = keyof SliderClasses;
-
-export function getSliderUtilityClass(slot: string): string {
- return generateUtilityClass(COMPONENT_NAME, slot);
-}
-
-export const sliderClasses: SliderClasses = generateUtilityClasses(COMPONENT_NAME, [
- 'root',
- 'active',
- 'focusVisible',
- 'disabled',
- 'dragging',
- 'marked',
- 'vertical',
- 'trackInverted',
- 'trackFalse',
- 'rail',
- 'track',
- 'mark',
- 'markActive',
- 'markLabel',
- 'markLabelActive',
- 'thumb',
-]);
diff --git a/packages/mui-base/src/Slider/utils.ts b/packages/mui-base/src/Slider/utils.ts
new file mode 100644
index 0000000000..95927f20ba
--- /dev/null
+++ b/packages/mui-base/src/Slider/utils.ts
@@ -0,0 +1,25 @@
+function getDecimalPrecision(num: number) {
+ // This handles the case when num is very small (0.00000001), js will turn this into 1e-8.
+ // When num is bigger than 1 or less than -1 it won't get converted to this notation so it's fine.
+ if (Math.abs(num) < 1) {
+ const parts = num.toExponential().split('e-');
+ const matissaDecimalPart = parts[0].split('.')[1];
+ return (matissaDecimalPart ? matissaDecimalPart.length : 0) + parseInt(parts[1], 10);
+ }
+
+ const decimalPart = num.toString().split('.')[1];
+ return decimalPart ? decimalPart.length : 0;
+}
+
+export function percentToValue(percent: number, min: number, max: number) {
+ return (max - min) * percent + min;
+}
+
+export function roundValueToStep(value: number, step: number, min: number) {
+ const nearest = Math.round((value - min) / step) * step + min;
+ return Number(nearest.toFixed(getDecimalPrecision(step)));
+}
+
+export function valueToPercent(value: number, min: number, max: number) {
+ return ((value - min) * 100) / (max - min);
+}
diff --git a/packages/mui-base/src/index.ts b/packages/mui-base/src/index.ts
index 47c3c388f9..9a78cfba56 100644
--- a/packages/mui-base/src/index.ts
+++ b/packages/mui-base/src/index.ts
@@ -3,6 +3,7 @@ export * from './Checkbox/index.barrel';
export * from './Dialog/index.barrel';
export * from './NumberField/index.barrel';
export * from './Popover/index.barrel';
+export * from './Slider/index.barrel';
export * from './Switch/index.barrel';
export * from './Tabs/index.barrel';
export * from './Tooltip/index.barrel';
diff --git a/packages/mui-base/src/useSlider/index.ts b/packages/mui-base/src/useSlider/index.ts
deleted file mode 100644
index f13ff862e3..0000000000
--- a/packages/mui-base/src/useSlider/index.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-'use client';
-export * from './useSlider';
-export * from './useSlider.types';
diff --git a/packages/mui-base/src/useSlider/useSlider.test.js b/packages/mui-base/src/useSlider/useSlider.test.js
deleted file mode 100644
index e4ee0313bd..0000000000
--- a/packages/mui-base/src/useSlider/useSlider.test.js
+++ /dev/null
@@ -1,91 +0,0 @@
-import * as React from 'react';
-import { expect } from 'chai';
-import { spy } from 'sinon';
-import { createRenderer, screen, fireEvent } from '@mui/internal-test-utils';
-import { useSlider } from './useSlider';
-
-describe('useSlider', () => {
- const { render } = createRenderer();
-
- describe('getRootProps', () => {
- it('forwards external props including event handlers', () => {
- const rootRef = React.createRef();
-
- const handleClick = spy();
-
- function Test() {
- const { getRootProps } = useSlider({
- rootRef,
- marks: [
- {
- label: 'One',
- value: 1,
- },
- ],
- });
-
- return (
-
- );
- }
-
- render();
-
- const slider = screen.getByTestId('test-slider-root');
- expect(slider).not.to.equal(null);
- expect(rootRef.current).to.deep.equal(slider);
-
- fireEvent.click(slider);
- expect(handleClick.callCount).to.equal(1);
- });
- });
-
- describe('getHiddenInputProps', () => {
- function Test(
- props = {
- slotProps: {
- input: {},
- },
- },
- ) {
- const { getRootProps, getThumbProps, getHiddenInputProps } = useSlider({
- marks: [
- {
- label: 'One',
- value: 1,
- },
- ],
- });
-
- return (
-
- );
- }
-
- it('forwards external props including event handlers', () => {
- const handleClick = spy();
- render(
- ,
- );
-
- const input = screen.getByTestId('test-input');
- expect(input).not.to.equal(null);
-
- fireEvent.click(input);
- expect(handleClick.callCount).to.equal(1);
- });
- });
-});
diff --git a/packages/mui-base/src/useSlider/useSlider.ts b/packages/mui-base/src/useSlider/useSlider.ts
deleted file mode 100644
index af5089a1e2..0000000000
--- a/packages/mui-base/src/useSlider/useSlider.ts
+++ /dev/null
@@ -1,758 +0,0 @@
-'use client';
-import * as React from 'react';
-import {
- 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,
- clamp,
-} from '@mui/utils';
-import {
- Mark,
- UseSliderHiddenInputProps,
- UseSliderParameters,
- UseSliderReturnValue,
- UseSliderRootSlotProps,
- UseSliderThumbSlotProps,
-} from './useSlider.types';
-import { areArraysEqual } from '../utils/areArraysEqual';
-import { EventHandlers } from '../utils/types';
-import { extractEventHandlers } from '../utils/extractEventHandlers';
-
-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 matissaDecimalPart = parts[0].split('.')[1];
- return (matissaDecimalPart ? matissaDecimalPart.length : 0) + parseInt(parts[1], 10);
- }
-
- const decimalPart = num.toString().split('.')[1];
- return decimalPart ? decimalPart.length : 0;
-}
-
-function roundValueToStep(value: number, step: number, min: number) {
- const nearest = Math.round((value - min) / step) * step + min;
- return Number(nearest.toFixed(getDecimalPrecision(step)));
-}
-
-function setValueIndex({
- values,
- newValue,
- index,
-}: {
- values: number[];
- newValue: number;
- index: number;
-}) {
- const output = values.slice();
- output[index] = newValue;
- return output.sort(asc);
-}
-
-function focusThumb({
- sliderRef,
- activeIndex,
- setActive,
-}: {
- sliderRef: React.RefObject;
- activeIndex: number;
- setActive?: (num: number) => void;
-}) {
- const doc = ownerDocument(sliderRef.current);
- if (
- !sliderRef.current?.contains(doc.activeElement) ||
- Number(doc?.activeElement?.getAttribute('data-index')) !== activeIndex
- ) {
- 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://mui.com/base-ui/react-slider/#hook)
- *
- * API:
- *
- * - [useSlider API](https://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();
- // We can't use the :active browser pseudo-classes.
- // - The active state isn't triggered when clicking on the rail.
- // - The active state isn't transferred when inversing a range slider.
- const [active, setActive] = React.useState(-1);
- const [open, setOpen] = React.useState(-1);
- const [dragging, setDragging] = React.useState(false);
- const moveCount = React.useRef(0);
-
- const [valueDerived, setValueState] = useControlled({
- controlled: valueProp,
- default: defaultValue ?? min,
- name: 'Slider',
- });
-
- const handleChange =
- onChange &&
- ((event: Event | React.SyntheticEvent, value: number | number[], thumbIndex: number) => {
- // Redefine target to allow name and value to be read.
- // This allows seamless integration with the most popular form libraries.
- // https://github.com/mui/material-ui/issues/13485#issuecomment-676048492
- // Clone the event to not override `target` of the original event.
- const nativeEvent = (event as React.SyntheticEvent).nativeEvent || event;
- // @ts-ignore The nativeEvent is function, not object
- const clonedEvent = new nativeEvent.constructor(nativeEvent.type, nativeEvent);
-
- Object.defineProperty(clonedEvent, 'target', {
- writable: true,
- value: { value, name },
- });
-
- onChange(clonedEvent, value, thumbIndex);
- });
-
- const range = Array.isArray(valueDerived);
- let values = range ? valueDerived.slice().sort(asc) : [valueDerived];
- values = values.map((value) => (value == null ? min : clamp(value, min, max)));
-
- const marks =
- marksProp === true && step !== null
- ? [...Array(Math.floor((max - min) / step) + 1)].map((_, index) => ({
- value: min + step * index,
- }))
- : marksProp || [];
-
- const marksValues = (marks as 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(null);
- 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) {
- // Bound the new value to the thumb's neighbours.
- if (disableSwap) {
- newValue = clamp(newValue, values[index - 1] || -Infinity, values[index + 1] || Infinity);
- }
-
- const previousValue = newValue;
- newValue = setValueIndex({
- values,
- newValue,
- index,
- });
-
- let activeIndex = index;
-
- // Potentially swap the index if needed.
- if (!disableSwap) {
- activeIndex = newValue.indexOf(previousValue);
- }
-
- focusThumb({ sliderRef, activeIndex });
- }
-
- setValueState(newValue);
- 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)) {
- // This is necessary because Firefox and Safari will keep focus
- // on a disabled element:
- // https://codesandbox.io/p/sandbox/mui-pr-22247-forked-h151h?file=/src/App.js
- // @ts-ignore
- document.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-ignore
- 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 = findClosest(values, newValue)!;
- } else {
- activeIndex = previousIndex.current!;
- }
-
- // Bound the new value to the thumb's neighbours.
- if (disableSwap) {
- newValue = clamp(
- newValue,
- values[activeIndex - 1] || -Infinity,
- values[activeIndex + 1] || Infinity,
- );
- }
-
- const previousValue = newValue;
- newValue = setValueIndex({
- values,
- newValue,
- index: activeIndex,
- });
-
- // Potentially swap the index if needed.
- if (!(disableSwap && move)) {
- activeIndex = newValue.indexOf(previousValue);
- previousIndex.current = activeIndex;
- }
- }
-
- return { newValue, activeIndex };
- };
-
- const handleTouchMove = useEventCallback((nativeEvent: TouchEvent | MouseEvent) => {
- const finger = trackFinger(nativeEvent, touchId);
-
- if (!finger) {
- return;
- }
-
- moveCount.current += 1;
-
- // Cancel move in case some other element consumed a mouseup event and it was not fired.
- // @ts-ignore buttons doesn't not exists on touch event
- if (nativeEvent.type === 'mousemove' && nativeEvent.buttons === 0) {
- // eslint-disable-next-line @typescript-eslint/no-use-before-define
- handleTouchEnd(nativeEvent);
- return;
- }
-
- const { newValue, activeIndex } = getFingerNewValue({
- finger,
- move: true,
- });
-
- focusThumb({ sliderRef, activeIndex, setActive });
- setValueState(newValue);
-
- if (!dragging && moveCount.current > INTENTIONAL_DRAG_COUNT_THRESHOLD) {
- setDragging(true);
- }
-
- if (handleChange && !areValuesEqual(newValue, valueDerived)) {
- handleChange(nativeEvent, newValue, activeIndex);
- }
- });
-
- const handleTouchEnd = useEventCallback((nativeEvent: TouchEvent | MouseEvent) => {
- const finger = trackFinger(nativeEvent, touchId);
- setDragging(false);
-
- if (!finger) {
- return;
- }
-
- const { newValue } = getFingerNewValue({ finger, move: true });
-
- setActive(-1);
- if (nativeEvent.type === 'touchend') {
- setOpen(-1);
- }
-
- if (onChangeCommitted) {
- onChangeCommitted(nativeEvent, newValue);
- }
-
- touchId.current = undefined;
-
- // eslint-disable-next-line @typescript-eslint/no-use-before-define
- stopListening();
- });
-
- const handleTouchStart = useEventCallback((nativeEvent: TouchEvent) => {
- if (disabled) {
- return;
- }
- // If touch-action: none; is not supported we need to prevent the scroll manually.
- if (!doesSupportTouchActionNone()) {
- nativeEvent.preventDefault();
- }
-
- const touch = nativeEvent.changedTouches[0];
- if (touch != null) {
- // A number that uniquely identifies the current finger in the touch session.
- touchId.current = touch.identifier;
- }
- const finger = trackFinger(nativeEvent, touchId);
- if (finger !== false) {
- const { newValue, activeIndex } = getFingerNewValue({ finger });
- 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 = = {}>(
- 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 = = {}>(
- 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 = = {}>(
- 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/mui-base/src/useSlider/useSlider.types.ts b/packages/mui-base/src/useSlider/useSlider.types.ts
deleted file mode 100644
index 38f411b5df..0000000000
--- a/packages/mui-base/src/useSlider/useSlider.types.ts
+++ /dev/null
@@ -1,259 +0,0 @@
-import * as React from 'react';
-
-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 interface Mark {
- value: number;
- label?: React.ReactNode;
-}
-
-export type UseSliderRootSlotOwnProps = {
- onMouseDown: React.MouseEventHandler;
- ref: React.RefCallback | null;
-};
-
-export type UseSliderRootSlotProps = Omit<
- ExternalProps,
- keyof UseSliderRootSlotOwnProps
-> &
- UseSliderRootSlotOwnProps;
-
-export type UseSliderThumbSlotOwnProps = {
- onMouseLeave: React.MouseEventHandler;
- onMouseOver: React.MouseEventHandler;
-};
-
-export type UseSliderThumbSlotProps = Omit<
- ExternalProps,
- keyof UseSliderThumbSlotOwnProps
-> &
- 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<
- ExternalProps,
- keyof UseSliderHiddenInputOwnProps
-> &
- 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: = {}>(
- 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: = {}>(
- 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: = {}>(
- 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/mui-base/src/utils/defaultRenderFunctions.tsx b/packages/mui-base/src/utils/defaultRenderFunctions.tsx
index 68d50e66ad..34682092b2 100644
--- a/packages/mui-base/src/utils/defaultRenderFunctions.tsx
+++ b/packages/mui-base/src/utils/defaultRenderFunctions.tsx
@@ -11,6 +11,9 @@ export const defaultRenderFunctions = {
// eslint-disable-next-line jsx-a11y/heading-has-content
return ;
},
+ output: (props: React.ComponentPropsWithRef<'output'>) => {
+ return ;
+ },
p: (props: React.ComponentPropsWithRef<'p'>) => {
return ;
},