Repository: mui/material-ui (TypeScript)
Date: 2020-12-09 06:51:56+00:00
Task type: Bug Fix

Issue:
[Slider] Component doesn't work on touch+AT devices
<!-- Provide a general summary of the issue in the Title above -->
Similar to this issue https://github.com/material-components/material-components-web-components/issues/1285

> The slider doesn't work for users on touch devices while using AT (e.g. iOS/VoiceOver, Android/TalkBack). The ARIA makes it announce like a native `<input type="range">`, and gives the hint to users that they can use gestures like "swipe up or down to change the value" (on iOS) / "use the volume buttons to change the value" (Android). However, as the slider can't actually intercept those (just yet anyway, until it uses Accessibility Object Model user action events https://wicg.github.io/aom/spec/#user-action-events), it is essentially broken for these users. 

You can see this broken behavior on any of the sliders here https://material-ui.com/components/slider/#slider by visiting them with a mobile device while voice over is enabled.

<!--
  Thank you very much for contributing to Material-UI by creating an issue! ❤️
  To avoid duplicate issues we ask you to check off the following list.
-->

<!-- Checked checkbox should look like this: [x] -->

- [x] The issue is present in the latest release.
- [x] I have searched the [issues](https://github.com/mui-org/material-ui/issues) of this repository and believe that this is not a duplicate.

## Current Behavior 😯

<!-- Describe what happens instead of the expected behavior. -->

On iOS with VO enabled navigating to the slider reads "Swipe up or down with one finger to adjust the value", but for instance on the volume slider it just keeps reading "30%" "30%" and the value doesn't change. 

## Expected Behavior 🤔

<!-- Describe what should happen. -->

The slider value should change as you "swipe up or down with one finger"

## Steps to Reproduce 🕹

<!--
  Provide a link to a live example (you can use codesandbox.io) and an unambiguous set of steps to reproduce this bug.
  Include code to reproduce, if relevant (which it most likely is).

  You should use the official codesandbox template as a starting point: https://material-ui.com/r/issue-template

  If you have an issue concerning TypeScript please start from this TypeScript playground: https://material-ui.com/r/ts-issue-template

  Issues without some form of live example have a longer response time.
-->

Either use this sandbox https://codesandbox.io/embed/material-ui-issue-forked-vn1f1?codemirror=1

Or any of the sliders on https://material-ui.com/components/slider/#slider

Steps:

1. On a mobile/tablet device, with AT running, navigate to the Slider example
2. Set focus to the slider (it should read "swipe up or down with one finger")
3. Attempt to change the value by following the prompt given by the AT (e.g. "swipe up or down" / "use volume keys" depending on the specific OS/AT)

## Context 🔦

<!--
  What are you trying to accomplish? How has this issue affected you?
  Providing context helps us come up with a solution that is most useful in the real world.
-->
I'm trying to get the slider to work for accessible users on ios and android

As the [linked issue](https://github.com/material-components/material-components-web-components/issues/1285) says:

> The most robust way to make this universally work, currently, is to actually have a proper `<input type="range">` which is either heavily styled, or which is being "piggy-backed" via CSS/JS. See https://codepen.io/patrickhlauke/pen/byWPMX for demo and explanation

Instead of the current implementation that uses a `<input type="hidden">` combined with the `role="slider"` on a `span`.

Because of the warning here https://w3c.github.io/aria-practices/#slider

> Warning
Users of touch-based assistive technologies may not be able to fully operate widgets that implement this pattern because APIs for capturing the necessary gestures and commands from assistive technologies are not yet available. Authors should fully test widgets using this pattern with assistive technologies before considering incorporation into production systems.

The apis are not available to support this version.

## Your Environment 🌎

<!--
  Include as many relevant details about the environment with which you experienced the bug.
  If you encounter issues with TypeScript please include version and tsconfig.
-->

OS: iOS, Android
Browser: All

| Tech        | Version |
| ----------- | ------- |
| Material-UI | next  |
| React       |    latest     |
| Browser     |     ALL (iOS, Android)     |
| TypeScript  |    N/A     |
| etc.        |         |



Issue discussion:
Interesting. The option to use a native slider was ignored to make customizations easier. But we could still imagine an approach similar to the rating. Use the native slider, but "hidden". In any case, this could require a couple of days of work. Would you be interested in working on it? 

https://github.com/w3c/aria-practices/issues/1038
Yeah I would. The code pen here https://codepen.io/patrickhlauke/pen/byWPMX seems to capture the best of both worlds, although I'm a little concerned about how to support the Multi-Thumb version, which I see you just linked, so probably thinking the same thing. I wonder if you could use multiple hidden `<input type="range">`, one for each Thumb?
> I wonder if you could use multiple hidden <input type="range">, one for each Thumb?

I have seen this pattern used in the past during a benchmark. However, I can't remember where 😔. 

Solution patch:
diff --git a/docs/src/pages/components/slider/CustomizedSlider.js b/docs/src/pages/components/slider/CustomizedSlider.js
--- a/docs/src/pages/components/slider/CustomizedSlider.js
+++ b/docs/src/pages/components/slider/CustomizedSlider.js
@@ -160,8 +160,10 @@ const AirbnbSlider = styled(Slider)({
 });
 
 function AirbnbThumbComponent(props) {
+  const { children, ...other } = props;
   return (
-    <SliderThumb {...props}>
+    <SliderThumb {...other}>
+      {children}
       <span className="bar" />
       <span className="bar" />
       <span className="bar" />
@@ -169,6 +171,10 @@ function AirbnbThumbComponent(props) {
   );
 }
 
+AirbnbThumbComponent.propTypes = {
+  children: PropTypes.node,
+};
+
 export default function CustomizedSlider() {
   return (
     <Root>
diff --git a/docs/src/pages/components/slider/CustomizedSlider.tsx b/docs/src/pages/components/slider/CustomizedSlider.tsx
--- a/docs/src/pages/components/slider/CustomizedSlider.tsx
+++ b/docs/src/pages/components/slider/CustomizedSlider.tsx
@@ -158,9 +158,13 @@ const AirbnbSlider = styled(Slider)({
   },
 });
 
-function AirbnbThumbComponent(props: any) {
+interface AirbnbThumbComponentProps extends React.HTMLAttributes<unknown> {}
+
+function AirbnbThumbComponent(props: AirbnbThumbComponentProps) {
+  const { children, ...other } = props;
   return (
-    <SliderThumb {...props}>
+    <SliderThumb {...other}>
+      {children}
       <span className="bar" />
       <span className="bar" />
       <span className="bar" />
diff --git a/docs/src/pages/components/slider/VerticalAccessibleSlider.js b/docs/src/pages/components/slider/VerticalAccessibleSlider.js
new file mode 100644
--- /dev/null
+++ b/docs/src/pages/components/slider/VerticalAccessibleSlider.js
@@ -0,0 +1,33 @@
+import * as React from 'react';
+import Box from '@material-ui/core/Box';
+import Typography from '@material-ui/core/Typography';
+import Slider from '@material-ui/core/Slider';
+
+export default function VerticalSlider() {
+  function preventHorizontalKeyboardNavigation(event) {
+    if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
+      event.preventDefault();
+    }
+  }
+
+  return (
+    <React.Fragment>
+      <Typography id="vertical-accessible-slider" gutterBottom>
+        Temperature
+      </Typography>
+      <Box sx={{ height: 300 }}>
+        <Slider
+          sx={{
+            '& input[type="range"]': {
+              WebkitAppearance: 'slider-vertical',
+            },
+          }}
+          orientation="vertical"
+          defaultValue={30}
+          aria-labelledby="vertical-accessible-slider"
+          onKeyDown={preventHorizontalKeyboardNavigation}
+        />
+      </Box>
+    </React.Fragment>
+  );
+}
diff --git a/docs/src/pages/components/slider/VerticalAccessibleSlider.tsx b/docs/src/pages/components/slider/VerticalAccessibleSlider.tsx
new file mode 100644
--- /dev/null
+++ b/docs/src/pages/components/slider/VerticalAccessibleSlider.tsx
@@ -0,0 +1,33 @@
+import * as React from 'react';
+import Box from '@material-ui/core/Box';
+import Typography from '@material-ui/core/Typography';
+import Slider from '@material-ui/core/Slider';
+
+export default function VerticalSlider() {
+  function preventHorizontalKeyboardNavigation(event: React.KeyboardEvent) {
+    if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
+      event.preventDefault();
+    }
+  }
+
+  return (
+    <React.Fragment>
+      <Typography id="vertical-accessible-slider" gutterBottom>
+        Temperature
+      </Typography>
+      <Box sx={{ height: 300 }}>
+        <Slider
+          sx={{
+            '& input[type="range"]': {
+              WebkitAppearance: 'slider-vertical',
+            },
+          }}
+          orientation="vertical"
+          defaultValue={30}
+          aria-labelledby="vertical-accessible-slider"
+          onKeyDown={preventHorizontalKeyboardNavigation}
+        />
+      </Box>
+    </React.Fragment>
+  );
+}
diff --git a/docs/src/pages/components/slider/slider.md b/docs/src/pages/components/slider/slider.md
--- a/docs/src/pages/components/slider/slider.md
+++ b/docs/src/pages/components/slider/slider.md
@@ -77,6 +77,16 @@ Here are some examples of customizing the component. You can learn more about th
 
 {{"demo": "pages/components/slider/VerticalSlider.js"}}
 
+**WARNING**: Chrome, Safari and newer Edge versions i.e. any browser based on WebKit exposes `<Slider orientation="vertical" />` as horizontal ([chromium issue #1158217](https://bugs.chromium.org/p/chromium/issues/detail?id=1158217)).
+By applying `-webkit-appearance: slider-vertical;` the slider is exposed as vertical.
+
+However, by applying `-webkit-appearance: slider-vertical;` keyboard navigation for horizontal keys (<kbd>Arrow Left</kbd>, <kbd>Arrow Right</kbd>) is reversed ([chromium issue #1162640](https://bugs.chromium.org/p/chromium/issues/detail?id=1162640)).
+Usually, up and right should increase and left and down should decrease the value.
+If you apply `-webkit-appearance` you could prevent keyboard navigation for horizontal arrow keys for a truly vertical slider.
+This might be less confusing to users compared to a change in direction.
+
+{{"demo": "pages/components/slider/VerticalAccessibleSlider.js"}}
+
 ## Track
 
 The track shows the range available for user selection.
diff --git a/packages/material-ui-unstyled/src/SliderUnstyled/SliderUnstyled.js b/packages/material-ui-unstyled/src/SliderUnstyled/SliderUnstyled.js
--- a/packages/material-ui-unstyled/src/SliderUnstyled/SliderUnstyled.js
+++ b/packages/material-ui-unstyled/src/SliderUnstyled/SliderUnstyled.js
@@ -9,6 +9,7 @@ import {
   unstable_useEventCallback as useEventCallback,
   unstable_useForkRef as useForkRef,
   unstable_useControlled as useControlled,
+  visuallyHidden,
 } from '@material-ui/utils';
 import isHostComponent from '../utils/isHostComponent';
 import sliderUnstyledClasses from './sliderUnstyledClasses';
@@ -19,6 +20,9 @@ function asc(a, b) {
 }
 
 function clamp(value, min, max) {
+  if (value == null) {
+    return min;
+  }
   return Math.min(Math.max(min, value), max);
 }
 
@@ -102,7 +106,7 @@ function focusThumb({ sliderRef, activeIndex, setActive }) {
     !sliderRef.current.contains(doc.activeElement) ||
     Number(doc.activeElement.getAttribute('data-index')) !== activeIndex
   ) {
-    sliderRef.current.querySelector(`[role="slider"][data-index="${activeIndex}"]`).focus();
+    sliderRef.current.querySelector(`[type="range"][data-index="${activeIndex}"]`).focus();
   }
 
   if (setActive) {
@@ -209,7 +213,7 @@ const SliderUnstyled = React.forwardRef(function SliderUnstyled(props, ref) {
     isRtl = false,
     components = {},
     componentsProps = {},
-    /* eslint-disable react/prop-types */
+    /* eslint-disable-next-line react/prop-types */
     theme,
     ...other
   } = props;
@@ -223,7 +227,7 @@ const SliderUnstyled = React.forwardRef(function SliderUnstyled(props, ref) {
 
   const [valueDerived, setValueState] = useControlled({
     controlled: valueProp,
-    default: defaultValue,
+    default: defaultValue ?? min,
     name: 'Slider',
   });
 
@@ -304,62 +308,30 @@ const SliderUnstyled = React.forwardRef(function SliderUnstyled(props, ref) {
     setFocusVisible(-1);
   }
 
-  const handleKeyDown = useEventCallback((event) => {
+  const handleHiddenInputChange = useEventCallback((event) => {
     const index = Number(event.currentTarget.getAttribute('data-index'));
     const value = values[index];
-    const tenPercents = (max - min) / 10;
     const marksValues = marks.map((mark) => mark.value);
     const marksIndex = marksValues.indexOf(value);
-    let newValue;
-    const increaseKey = isRtl ? 'ArrowLeft' : 'ArrowRight';
-    const decreaseKey = isRtl ? 'ArrowRight' : 'ArrowLeft';
-
-    switch (event.key) {
-      case 'Home':
-        newValue = min;
-        break;
-      case 'End':
-        newValue = max;
-        break;
-      case 'PageUp':
-        if (step) {
-          newValue = value + tenPercents;
-        }
-        break;
-      case 'PageDown':
-        if (step) {
-          newValue = value - tenPercents;
-        }
-        break;
-      case increaseKey:
-      case 'ArrowUp':
-        if (step) {
-          newValue = value + step;
-        } else {
-          newValue = marksValues[marksIndex + 1] || marksValues[marksValues.length - 1];
-        }
-        break;
-      case decreaseKey:
-      case 'ArrowDown':
-        if (step) {
-          newValue = value - step;
-        } else {
-          newValue = marksValues[marksIndex - 1] || marksValues[0];
-        }
-        break;
-      default:
-        return;
-    }
 
-    // Prevent scroll of the page
-    event.preventDefault();
+    let newValue = event.target.valueAsNumber;
 
-    if (step) {
-      newValue = roundValueToStep(newValue, step, min);
+    if (marks && step == null) {
+      newValue = newValue < value ? marksValues[marksIndex - 1] : marksValues[marksIndex + 1];
     }
 
     newValue = clamp(newValue, min, max);
 
+    if (marks && step == null) {
+      const markValues = marks.map((mark) => mark.value);
+      const currentMarkIndex = markValues.indexOf(values[index]);
+
+      newValue =
+        newValue < values[index]
+          ? markValues[currentMarkIndex - 1]
+          : markValues[currentMarkIndex + 1];
+    }
+
     if (range) {
       const previousValue = newValue;
       newValue = setValueIndex({
@@ -377,6 +349,7 @@ const SliderUnstyled = React.forwardRef(function SliderUnstyled(props, ref) {
     if (handleChange) {
       handleChange(event, newValue);
     }
+
     if (onChangeCommitted) {
       onChangeCommitted(event, newValue);
     }
@@ -650,7 +623,6 @@ const SliderUnstyled = React.forwardRef(function SliderUnstyled(props, ref) {
         className={clsx(utilityClasses.track, trackProps.className)}
         style={{ ...trackStyle, ...trackProps.style }}
       />
-      <input value={values.join(',')} name={name} type="hidden" />
       {marks.map((mark, index) => {
         const percent = valueToPercent(mark.value, min, max);
         const style = axisProps[axis].offset(percent);
@@ -715,55 +687,72 @@ const SliderUnstyled = React.forwardRef(function SliderUnstyled(props, ref) {
         const ValueLabelComponent = valueLabelDisplay === 'off' ? Forward : ValueLabel;
 
         return (
-          <ValueLabelComponent
-            key={index}
-            valueLabelFormat={valueLabelFormat}
-            valueLabelDisplay={valueLabelDisplay}
-            value={
-              typeof valueLabelFormat === 'function'
-                ? valueLabelFormat(scale(value), index)
-                : valueLabelFormat
-            }
-            index={index}
-            open={open === index || active === index || valueLabelDisplay === 'on'}
-            disabled={disabled}
-            {...valueLabelProps}
-            className={clsx(utilityClasses.valueLabel, valueLabelProps.className)}
-            {...(!isHostComponent(ValueLabel) && {
-              styleProps: { ...styleProps, ...valueLabelProps.styleProps },
-              theme,
-            })}
-          >
-            <Thumb
-              tabIndex={disabled ? null : 0}
-              role="slider"
-              data-index={index}
-              aria-label={getAriaLabel ? getAriaLabel(index) : ariaLabel}
-              aria-labelledby={ariaLabelledby}
-              aria-orientation={orientation}
-              aria-valuemax={scale(max)}
-              aria-valuemin={scale(min)}
-              aria-valuenow={scale(value)}
-              aria-valuetext={
-                getAriaValueText ? getAriaValueText(scale(value), index) : ariaValuetext
+          <React.Fragment key={index}>
+            <ValueLabelComponent
+              valueLabelFormat={valueLabelFormat}
+              valueLabelDisplay={valueLabelDisplay}
+              value={
+                typeof valueLabelFormat === 'function'
+                  ? valueLabelFormat(scale(value), index)
+                  : valueLabelFormat
               }
-              onKeyDown={handleKeyDown}
-              onFocus={handleFocus}
-              onBlur={handleBlur}
-              onMouseOver={handleMouseOver}
-              onMouseLeave={handleMouseLeave}
-              {...thumbProps}
-              className={clsx(utilityClasses.thumb, thumbProps.className, {
-                [utilityClasses.active]: active === index,
-                [utilityClasses.focusVisible]: focusVisible === index,
-              })}
-              {...(!isHostComponent(Thumb) && {
-                styleProps: { ...styleProps, ...thumbProps.styleProps },
+              index={index}
+              open={open === index || active === index || valueLabelDisplay === 'on'}
+              disabled={disabled}
+              {...valueLabelProps}
+              className={clsx(utilityClasses.valueLabel, valueLabelProps.className)}
+              {...(!isHostComponent(ValueLabel) && {
+                styleProps: { ...styleProps, ...valueLabelProps.styleProps },
                 theme,
               })}
-              style={{ ...style, ...thumbProps.style }}
-            />
-          </ValueLabelComponent>
+            >
+              <Thumb
+                data-index={index}
+                onMouseOver={handleMouseOver}
+                onMouseLeave={handleMouseLeave}
+                {...thumbProps}
+                className={clsx(utilityClasses.thumb, thumbProps.className, {
+                  [utilityClasses['active']]: active === index,
+                  [utilityClasses['focusVisible']]: focusVisible === index,
+                })}
+                {...(!isHostComponent(Thumb) && {
+                  styleProps: { ...styleProps, ...thumbProps.styleProps },
+                  theme,
+                })}
+                style={{ ...style, ...thumbProps.style }}
+              >
+                <input
+                  data-index={index}
+                  aria-label={getAriaLabel ? getAriaLabel(index) : ariaLabel}
+                  aria-labelledby={ariaLabelledby}
+                  aria-orientation={orientation}
+                  aria-valuemax={scale(max)}
+                  aria-valuemin={scale(min)}
+                  aria-valuenow={scale(value)}
+                  aria-valuetext={
+                    getAriaValueText ? getAriaValueText(scale(value), index) : ariaValuetext
+                  }
+                  onFocus={handleFocus}
+                  onBlur={handleBlur}
+                  name={name}
+                  type="range"
+                  min={props.min}
+                  max={props.max}
+                  step={props.step}
+                  disabled={disabled}
+                  value={values[index]}
+                  onChange={handleHiddenInputChange}
+                  style={{
+                    ...visuallyHidden,
+                    direction: isRtl ? 'rtl' : 'ltr',
+                    // So that VoiceOver's focus indicator matches the thumb's dimensions
+                    width: '100%',
+                    height: '100%',
+                  }}
+                />
+              </Thumb>
+            </ValueLabelComponent>
+          </React.Fragment>
         );
       })}
     </Root>
diff --git a/packages/material-ui-unstyled/src/SliderUnstyled/SliderValueLabelUnstyled.js b/packages/material-ui-unstyled/src/SliderUnstyled/SliderValueLabelUnstyled.js
--- a/packages/material-ui-unstyled/src/SliderUnstyled/SliderValueLabelUnstyled.js
+++ b/packages/material-ui-unstyled/src/SliderUnstyled/SliderValueLabelUnstyled.js
@@ -1,4 +1,5 @@
 import * as React from 'react';
+import PropTypes from 'prop-types';
 import clsx from 'clsx';
 import sliderUnstyledClasses from './sliderUnstyledClasses';
 
@@ -30,12 +31,23 @@ function SliderValueLabelUnstyled(props) {
     {
       className: clsx(children.props.className),
     },
-    <Root className={clsx(classes.offset, className)} theme={theme}>
-      <span className={classes.circle}>
-        <span className={classes.label}>{value}</span>
-      </span>
-    </Root>,
+    <React.Fragment>
+      {children.props.children}
+      <Root className={clsx(classes.offset, className)} theme={theme} aria-hidden>
+        <span className={classes.circle}>
+          <span className={classes.label}>{value}</span>
+        </span>
+      </Root>
+    </React.Fragment>,
   );
 }
 
+SliderValueLabelUnstyled.propTypes = {
+  children: PropTypes.element.isRequired,
+  className: PropTypes.string,
+  components: PropTypes.shape({ Root: PropTypes.elementType }),
+  theme: PropTypes.any,
+  value: PropTypes.node,
+};
+
 export default SliderValueLabelUnstyled;


Test patch:
diff --git a/packages/material-ui-unstyled/src/SliderUnstyled/SliderUnstyled.test.js b/packages/material-ui-unstyled/src/SliderUnstyled/SliderUnstyled.test.js
--- a/packages/material-ui-unstyled/src/SliderUnstyled/SliderUnstyled.test.js
+++ b/packages/material-ui-unstyled/src/SliderUnstyled/SliderUnstyled.test.js
@@ -1,6 +1,6 @@
 import * as React from 'react';
 import { expect } from 'chai';
-import { createMount, createClientRender, describeConformance } from 'test/utils';
+import { createMount, createClientRender, describeConformance, screen } from 'test/utils';
 import SliderUnstyled, {
   sliderUnstyledClasses as classes,
 } from '@material-ui/unstyled/SliderUnstyled';
@@ -56,4 +56,37 @@ describe('<SliderUnstyled />', () => {
     expect(element.getAttribute('styleProps')).to.equal(null);
     expect(element.getAttribute('theme')).to.equal(null);
   });
+
+  describe('prop: orientation', () => {
+    it('sets the orientation via ARIA', () => {
+      render(<SliderUnstyled orientation="vertical" />);
+
+      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(<SliderUnstyled orientation="vertical" />);
+
+      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' });
+    });
+  });
+
+  describe('prop: valueLabelDisplay', () => {
+    it('renders a slider', () => {
+      render(<SliderUnstyled value={30} valueLabelDisplay="auto" />);
+
+      expect(screen.getByRole('slider')).to.have.attribute('aria-valuenow', '30');
+    });
+  });
 });
diff --git a/packages/material-ui/src/Slider/Slider.test.js b/packages/material-ui/src/Slider/Slider.test.js
--- a/packages/material-ui/src/Slider/Slider.test.js
+++ b/packages/material-ui/src/Slider/Slider.test.js
@@ -69,9 +69,7 @@ describe('<Slider />', () => {
     expect(handleChangeCommitted.args[0][1]).to.equal(10);
 
     slider.focus();
-    fireEvent.keyDown(slider, {
-      key: 'Home',
-    });
+    fireEvent.change(slider, { target: { value: 23 } });
     expect(handleChange.callCount).to.equal(2);
     expect(handleChangeCommitted.callCount).to.equal(2);
   });
@@ -171,32 +169,66 @@ describe('<Slider />', () => {
   describe('range', () => {
     it('should support keyboard', () => {
       const { getAllByRole } = render(<Slider defaultValue={[20, 30]} />);
-      const [thumb1, thumb2] = getAllByRole('slider');
+      const [slider1, slider2] = getAllByRole('slider');
+
+      act(() => {
+        slider1.focus();
+        fireEvent.change(slider1, { target: { value: '21' } });
+      });
+
+      expect(slider1.getAttribute('aria-valuenow')).to.equal('21');
+      expect(slider2.getAttribute('aria-valuenow')).to.equal('30');
+
+      act(() => {
+        slider2.focus();
+        fireEvent.change(slider2, { target: { value: '31' } });
+      });
+
+      expect(slider1.getAttribute('aria-valuenow')).to.equal('21');
+      expect(slider2.getAttribute('aria-valuenow')).to.equal('31');
 
       act(() => {
-        thumb1.focus();
-        fireEvent.keyDown(thumb1, {
-          key: 'ArrowRight',
-        });
+        slider1.focus();
+        fireEvent.change(slider1, { target: { value: '31' } });
       });
 
-      expect(thumb1.getAttribute('aria-valuenow')).to.equal('21');
+      expect(slider1.getAttribute('aria-valuenow')).to.equal('31');
+      expect(slider2.getAttribute('aria-valuenow')).to.equal('31');
+      expect(document.activeElement).to.have.attribute('data-index', '0');
 
       act(() => {
-        thumb2.focus();
-        fireEvent.keyDown(thumb2, {
-          key: 'ArrowLeft',
-        });
+        slider1.focus();
+        fireEvent.change(slider1, { target: { value: '32' } });
       });
 
-      expect(thumb2.getAttribute('aria-valuenow')).to.equal('29');
+      expect(slider1.getAttribute('aria-valuenow')).to.equal('31');
+      expect(slider2.getAttribute('aria-valuenow')).to.equal('32');
+      expect(document.activeElement).to.have.attribute('data-index', '1');
     });
 
     it('should focus the slider when dragging', () => {
-      const { getByRole } = render(<Slider defaultValue={30} step={10} marks />);
-      const thumb = getByRole('slider');
-      fireEvent.mouseDown(thumb);
-      expect(thumb).toHaveFocus();
+      const { getByRole, getByTestId, container } = render(
+        <Slider
+          componentsProps={{ thumb: { 'data-testid': 'thumb' } }}
+          defaultValue={30}
+          step={10}
+          marks
+        />,
+      );
+      const slider = getByRole('slider');
+      const thumb = getByTestId('thumb');
+
+      stub(container.firstChild, 'getBoundingClientRect').callsFake(() => ({
+        width: 100,
+        left: 0,
+      }));
+
+      fireEvent.mouseDown(thumb, {
+        buttons: 1,
+        clientX: 1,
+      });
+
+      expect(slider).toHaveFocus();
     });
 
     it('should support mouse events', () => {
@@ -282,24 +314,157 @@ describe('<Slider />', () => {
         bottom: 10,
         left: 0,
       }));
-      const thumb = getByRole('slider');
+      const slider = getByRole('slider');
 
       fireEvent.touchStart(
         container.firstChild,
         createTouches([{ identifier: 1, clientX: 21, clientY: 0 }]),
       );
-      expect(thumb).to.have.attribute('aria-valuenow', '20');
+      expect(slider).to.have.attribute('aria-valuenow', '20');
 
-      thumb.focus();
-      fireEvent.keyDown(thumb, {
-        key: 'ArrowUp',
+      fireEvent.change(slider, {
+        target: {
+          value: 21,
+        },
       });
-      expect(thumb).to.have.attribute('aria-valuenow', '30');
+      expect(slider).to.have.attribute('aria-valuenow', '30');
 
-      fireEvent.keyDown(thumb, {
-        key: 'ArrowDown',
+      fireEvent.change(slider, {
+        target: {
+          value: 29,
+        },
       });
-      expect(thumb).to.have.attribute('aria-valuenow', '20');
+      expect(slider).to.have.attribute('aria-valuenow', '20');
+    });
+
+    it('change events with non integer numbers should work', () => {
+      const { getByRole } = render(
+        <Slider defaultValue={0.2} min={-100} max={100} step={0.00000001} />,
+      );
+      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, container } = render(
+        <Slider defaultValue={0.2} min={0} max={1} step={0.1} />,
+      );
+      const slider = getByRole('slider');
+
+      act(() => {
+        slider.focus();
+      });
+
+      stub(container.firstChild, 'getBoundingClientRect').callsFake(() => ({
+        width: 100,
+        height: 10,
+        bottom: 10,
+        left: 0,
+      }));
+
+      act(() => {
+        slider.focus();
+      });
+
+      expect(slider).to.have.attribute('aria-valuenow', '0.2');
+
+      fireEvent.touchStart(
+        container.firstChild,
+        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, container } = render(
+        <Slider defaultValue={0.00000002} min={0} max={0.0000001} step={0.00000001} />,
+      );
+      const slider = getByRole('slider');
+
+      act(() => {
+        slider.focus();
+      });
+
+      stub(container.firstChild, 'getBoundingClientRect').callsFake(() => ({
+        width: 100,
+        height: 10,
+        bottom: 10,
+        left: 0,
+      }));
+
+      act(() => {
+        slider.focus();
+      });
+
+      expect(slider).to.have.attribute('aria-valuenow', '2e-8');
+
+      fireEvent.touchStart(
+        container.firstChild,
+        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, container } = render(
+        <Slider defaultValue={-0.00000002} min={-0.0000001} max={0} step={0.00000001} />,
+      );
+      const slider = getByRole('slider');
+
+      act(() => {
+        slider.focus();
+      });
+
+      stub(container.firstChild, 'getBoundingClientRect').callsFake(() => ({
+        width: 100,
+        height: 10,
+        bottom: 10,
+        left: 0,
+      }));
+
+      act(() => {
+        slider.focus();
+      });
+
+      expect(slider).to.have.attribute('aria-valuenow', '-2e-8');
+
+      fireEvent.touchStart(
+        container.firstChild,
+        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');
     });
   });
 
@@ -377,182 +542,159 @@ describe('<Slider />', () => {
     });
   });
 
-  describe('keyboard', () => {
-    it('should handle all the keys', () => {
+  describe('aria-valuenow', () => {
+    it('should update the aria-valuenow', () => {
       const { getByRole } = render(<Slider defaultValue={50} />);
-      const thumb = getByRole('slider');
+      const slider = getByRole('slider');
       act(() => {
-        thumb.focus();
-      });
-
-      fireEvent.keyDown(thumb, {
-        key: 'Home',
+        slider.focus();
       });
-      expect(thumb).to.have.attribute('aria-valuenow', '0');
 
-      fireEvent.keyDown(thumb, {
-        key: 'End',
-      });
-      expect(thumb).to.have.attribute('aria-valuenow', '100');
+      fireEvent.change(slider, { target: { value: 51 } });
+      expect(slider).to.have.attribute('aria-valuenow', '51');
 
-      fireEvent.keyDown(thumb, {
-        key: 'PageDown',
-      });
-      expect(thumb).to.have.attribute('aria-valuenow', '90');
+      fireEvent.change(slider, { target: { value: 52 } });
+      expect(slider).to.have.attribute('aria-valuenow', '52');
+    });
+  });
 
-      fireEvent.keyDown(thumb, {
-        key: 'Escape',
-      });
-      expect(thumb).to.have.attribute('aria-valuenow', '90');
+  describe('prop: min', () => {
+    it('should set the min and aria-valuemin on the input', () => {
+      const min = 150;
+      const { getByRole } = render(<Slider defaultValue={150} step={100} max={750} min={min} />);
+      const slider = getByRole('slider');
 
-      fireEvent.keyDown(thumb, {
-        key: 'PageUp',
-      });
-      expect(thumb).to.have.attribute('aria-valuenow', '100');
+      expect(slider).to.have.attribute('aria-valuemin', String(min));
+      expect(slider).to.have.attribute('min', String(min));
     });
 
-    const moveLeftEvent = {
-      key: 'ArrowLeft',
-    };
-    const moveRightEvent = {
-      key: 'ArrowRight',
-    };
-
     it('should use min as the step origin', () => {
-      const { getByRole } = render(<Slider defaultValue={150} step={100} max={750} min={150} />);
-      const thumb = getByRole('slider');
+      const min = 150;
+      const { getByRole } = render(<Slider defaultValue={150} step={100} max={750} min={min} />);
+      const slider = getByRole('slider');
       act(() => {
-        thumb.focus();
+        slider.focus();
       });
 
-      fireEvent.keyDown(thumb, moveRightEvent);
-      expect(thumb).to.have.attribute('aria-valuenow', '250');
-
-      fireEvent.keyDown(thumb, moveLeftEvent);
-      expect(thumb).to.have.attribute('aria-valuenow', '150');
+      expect(slider).to.have.attribute('aria-valuenow', String(min));
     });
 
-    it('should reach right edge value', () => {
-      const { getByRole } = render(<Slider defaultValue={90} min={6} max={108} step={10} />);
-      const thumb = getByRole('slider');
+    it('should not go less than the min', () => {
+      const min = 150;
+      const { getByRole } = render(<Slider defaultValue={150} step={100} max={750} min={min} />);
+      const slider = getByRole('slider');
       act(() => {
-        thumb.focus();
+        slider.focus();
       });
 
-      fireEvent.keyDown(thumb, moveRightEvent);
-      expect(thumb).to.have.attribute('aria-valuenow', '96');
-
-      fireEvent.keyDown(thumb, moveRightEvent);
-      expect(thumb).to.have.attribute('aria-valuenow', '106');
-
-      fireEvent.keyDown(thumb, moveRightEvent);
-      expect(thumb).to.have.attribute('aria-valuenow', '108');
-
-      fireEvent.keyDown(thumb, moveLeftEvent);
-      expect(thumb).to.have.attribute('aria-valuenow', '96');
+      fireEvent.change(slider, { target: { value: String(min - 100) } });
+      expect(slider).to.have.attribute('aria-valuenow', String(min));
+    });
+  });
+  describe('prop: max', () => {
+    it('should set the max and aria-valuemax on the input', () => {
+      const max = 750;
+      const { getByRole } = render(<Slider defaultValue={150} step={100} max={max} min={150} />);
+      const slider = getByRole('slider');
 
-      fireEvent.keyDown(thumb, moveLeftEvent);
-      expect(thumb).to.have.attribute('aria-valuenow', '86');
+      expect(slider).to.have.attribute('aria-valuemax', String(max));
+      expect(slider).to.have.attribute('max', String(max));
     });
 
-    it('should reach left edge value', () => {
-      const { getByRole } = render(<Slider defaultValue={20} min={6} max={108} step={10} />);
-      const thumb = getByRole('slider');
+    it('should not go more than the max', () => {
+      const max = 750;
+      const { getByRole } = render(<Slider defaultValue={150} step={100} max={max} min={150} />);
+      const slider = getByRole('slider');
       act(() => {
-        thumb.focus();
+        slider.focus();
       });
 
-      fireEvent.keyDown(thumb, moveLeftEvent);
-      expect(thumb).to.have.attribute('aria-valuenow', '6');
+      fireEvent.change(slider, { target: { value: String(max + 100) } });
+      expect(slider).to.have.attribute('aria-valuenow', String(max));
+    });
 
-      fireEvent.keyDown(thumb, moveRightEvent);
-      expect(thumb).to.have.attribute('aria-valuenow', '16');
+    it('should reach right edge value', () => {
+      const { getByRole, container } = render(
+        <Slider defaultValue={90} min={6} max={108} step={10} />,
+      );
 
-      fireEvent.keyDown(thumb, moveRightEvent);
-      expect(thumb).to.have.attribute('aria-valuenow', '26');
-    });
+      stub(container.firstChild, 'getBoundingClientRect').callsFake(() => ({
+        width: 100,
+        height: 10,
+        bottom: 10,
+        left: 0,
+      }));
 
-    it('should round value to step precision', () => {
-      const { getByRole } = render(<Slider defaultValue={0.2} min={0} max={1} step={0.1} />);
       const thumb = getByRole('slider');
       act(() => {
         thumb.focus();
       });
 
-      fireEvent.keyDown(thumb, moveRightEvent);
-      expect(thumb).to.have.attribute('aria-valuenow', '0.3');
-    });
+      expect(thumb).to.have.attribute('aria-valuenow', '90');
 
-    it('should not fail to round value to step precision when step is very small', () => {
-      const { getByRole } = render(
-        <Slider defaultValue={0.00000002} min={0} max={0.00000005} step={0.00000001} />,
+      fireEvent.touchStart(
+        container.firstChild,
+        createTouches([{ identifier: 1, clientX: 20, clientY: 0 }]),
       );
-      const thumb = getByRole('slider');
-      act(() => {
-        thumb.focus();
-      });
-
-      fireEvent.keyDown(thumb, moveRightEvent);
-      expect(thumb).to.have.attribute('aria-valuenow', '3e-8');
-    });
 
-    it('should not fail to round value to step precision when step is very small and negative', () => {
-      const { getByRole } = render(
-        <Slider defaultValue={-0.00000002} min={-0.00000005} max={0} step={0.00000001} />,
+      fireEvent.touchMove(
+        document.body,
+        createTouches([{ identifier: 1, clientX: 100, clientY: 0 }]),
       );
-      const thumb = getByRole('slider');
-      act(() => {
-        thumb.focus();
-      });
+      expect(thumb).to.have.attribute('aria-valuenow', '106');
 
-      fireEvent.keyDown(thumb, moveLeftEvent);
-      expect(thumb).to.have.attribute('aria-valuenow', '-3e-8');
-    });
+      fireEvent.touchMove(
+        document.body,
+        createTouches([{ identifier: 1, clientX: 200, clientY: 0 }]),
+      );
+      expect(thumb).to.have.attribute('aria-valuenow', '108');
 
-    it('should handle RTL', () => {
-      const { getByRole } = render(
-        <ThemeProvider
-          theme={createMuiTheme({
-            direction: 'rtl',
-          })}
-        >
-          <Slider defaultValue={30} />
-        </ThemeProvider>,
+      fireEvent.touchMove(
+        document.body,
+        createTouches([{ identifier: 1, clientX: 50, clientY: 0 }]),
       );
-      const thumb = getByRole('slider');
-      act(() => {
-        thumb.focus();
-      });
+      expect(thumb).to.have.attribute('aria-valuenow', '56');
 
-      fireEvent.keyDown(thumb, moveLeftEvent);
-      expect(thumb).to.have.attribute('aria-valuenow', '31');
-      fireEvent.keyDown(thumb, moveRightEvent);
-      expect(thumb).to.have.attribute('aria-valuenow', '30');
+      fireEvent.touchMove(
+        document.body,
+        createTouches([{ identifier: 1, clientX: -100, clientY: 0 }]),
+      );
+      expect(thumb).to.have.attribute('aria-valuenow', '6');
     });
   });
 
   describe('prop: valueLabelDisplay', () => {
     it('should always display the value label according to on and off', () => {
-      const { getByRole, setProps } = render(<Slider valueLabelDisplay="on" value={50} />);
-      const thumb = getByRole('slider');
-      expect(thumb.firstChild).to.have.class(classes.valueLabelOpen);
+      const { setProps } = render(
+        <Slider
+          valueLabelDisplay="on"
+          value={50}
+          componentsProps={{ thumb: { 'data-testid': 'thumb' } }}
+        />,
+      );
+      expect(document.querySelector(`.${classes.valueLabelOpen}`)).not.to.equal(null);
 
       setProps({
         valueLabelDisplay: 'off',
       });
 
-      const newThumb = getByRole('slider');
-      expect(newThumb.firstChild).to.equal(null);
+      expect(document.querySelector(`.${classes.valueLabelOpen}`)).to.equal(null);
     });
 
     it('should display the value label only on hover for auto', () => {
-      const { getByRole } = render(<Slider valueLabelDisplay="auto" value={50} />);
-      const thumb = getByRole('slider');
-      expect(thumb.firstChild).not.to.have.class(classes.valueLabelOpen);
+      const { getByTestId } = render(
+        <Slider
+          valueLabelDisplay="auto"
+          value={50}
+          componentsProps={{ thumb: { 'data-testid': 'thumb' } }}
+        />,
+      );
+      const thumb = getByTestId('thumb');
+      expect(document.querySelector(`.${classes.valueLabelOpen}`)).to.equal(null);
 
       fireEvent.mouseOver(thumb);
 
-      expect(thumb.firstChild).to.have.class(classes.valueLabelOpen);
+      expect(document.querySelector(`.${classes.valueLabelOpen}`)).not.to.equal(null);
     });
 
     it('should be respected when using custom value label', () => {
@@ -639,37 +781,64 @@ describe('<Slider />', () => {
     expect(handleMouseDown.callCount).to.equal(1);
   });
 
-  it('should handle RTL', () => {
-    const handleChange = spy();
-    const { container, getByRole } = render(
-      <ThemeProvider
-        theme={createMuiTheme({
-          direction: 'rtl',
-        })}
-      >
-        <Slider value={30} onChange={handleChange} />
-      </ThemeProvider>,
-    );
-    const thumb = getByRole('slider');
-    expect(thumb.style.right).to.equal('30%');
+  describe('rtl', () => {
+    it('should add direction css', () => {
+      const { getByRole } = render(
+        <ThemeProvider
+          theme={createMuiTheme({
+            direction: 'rtl',
+          })}
+        >
+          <Slider defaultValue={30} />
+        </ThemeProvider>,
+      );
+      const thumb = getByRole('slider');
+      act(() => {
+        thumb.focus();
+      });
 
-    stub(container.firstChild, 'getBoundingClientRect').callsFake(() => ({
-      width: 100,
-      height: 10,
-      bottom: 10,
-      left: 0,
-    }));
+      expect(thumb.style.direction).to.equal('rtl');
+    });
 
-    fireEvent.touchStart(
-      container.firstChild,
-      createTouches([{ identifier: 1, clientX: 20, clientY: 0 }]),
-    );
+    it('should handle RTL', () => {
+      const handleChange = spy();
+      const { container, getByTestId } = render(
+        <ThemeProvider
+          theme={createMuiTheme({
+            direction: 'rtl',
+          })}
+        >
+          <Slider
+            value={30}
+            onChange={handleChange}
+            componentsProps={{ thumb: { 'data-testid': 'thumb' } }}
+          />
+        </ThemeProvider>,
+      );
+      const thumb = getByTestId('thumb');
+      expect(thumb.style.right).to.equal('30%');
+
+      stub(container.firstChild, 'getBoundingClientRect').callsFake(() => ({
+        width: 100,
+        height: 10,
+        bottom: 10,
+        left: 0,
+      }));
 
-    fireEvent.touchMove(document.body, createTouches([{ identifier: 1, clientX: 22, clientY: 0 }]));
+      fireEvent.touchStart(
+        container.firstChild,
+        createTouches([{ identifier: 1, clientX: 20, clientY: 0 }]),
+      );
 
-    expect(handleChange.callCount).to.equal(2);
-    expect(handleChange.args[0][1]).to.equal(80);
-    expect(handleChange.args[1][1]).to.equal(78);
+      fireEvent.touchMove(
+        document.body,
+        createTouches([{ identifier: 1, clientX: 22, clientY: 0 }]),
+      );
+
+      expect(handleChange.callCount).to.equal(2);
+      expect(handleChange.args[0][1]).to.equal(80);
+      expect(handleChange.args[1][1]).to.equal(78);
+    });
   });
 
   describe('warnings', () => {
@@ -762,12 +931,14 @@ describe('<Slider />', () => {
     const { getByRole } = render(
       <Slider onChange={handleChange} name="change-testing" value={3} />,
     );
-    const thumb = getByRole('slider');
+    const slider = getByRole('slider');
 
     act(() => {
-      thumb.focus();
-      fireEvent.keyDown(thumb, {
-        key: 'ArrowRight',
+      slider.focus();
+      fireEvent.change(slider, {
+        target: {
+          value: 4,
+        },
       });
     });
 
diff --git a/test/utils/initMatchers.ts b/test/utils/initMatchers.ts
--- a/test/utils/initMatchers.ts
+++ b/test/utils/initMatchers.ts
@@ -330,7 +330,12 @@ chai.use((chaiAPI, utils) => {
     // This is closer to actual CSS and required for getPropertyValue anyway.
     const expectedStyle: Record<string, string> = {};
     Object.keys(expectedStyleUnnormalized).forEach((cssProperty) => {
-      expectedStyle[_.kebabCase(cssProperty)] = expectedStyleUnnormalized[cssProperty];
+      const hyphenCasedPropertyName = _.kebabCase(cssProperty);
+      const isVendorPrefixed = /^(moz|ms|o|webkit)-/.test(hyphenCasedPropertyName);
+      const propertyName = isVendorPrefixed
+        ? `-${hyphenCasedPropertyName}`
+        : hyphenCasedPropertyName;
+      expectedStyle[propertyName] = expectedStyleUnnormalized[cssProperty];
     });
 
     const actualStyle: Record<string, string> = {};