Repository: mui/material-ui (TypeScript)
Date: 2019-02-08 10:01:56+00:00
Task type: Bug Fix

Issue:
[Slider] - Slider handle is a button without an accessible name
<!--- Provide a general summary of the issue in the Title above -->
Lighthouse gives the following warning:
> When a button doesn't have an accessible name, screen readers announce it as "button", making it unusable for users who rely on screen readers

I can trace it to the Slider component, which uses a button element as the handle, but does not give it a name.

<!-- Checked checkbox should look like this: [x] -->
- [x] This is not a v0.x issue. <!-- (v0.x is no longer maintained) -->
- [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.

(By the title, #13485 sounds like it's the same as this, but it's not.)

## Expected Behavior 🤔
From [this accessibility guide](https://dequeuniversity.com/rules/axe/3.1/button-name?application=lighthouse) that Lighthouse links to:

Ensure that each button element and elements with role="button" have one of the following characteristics:

- Inner text that is discernible to screen reader users.
- Non-empty aria-label attribute.
- `aria-labelledby` pointing to element with text which is discernible to screen reader users (i.e. not `display: none;` or `aria-hidden="true"`.
- `role="presentation"` or `role="none"` (ARIA 1.1) and is not in tab order (`tabindex="-1"`).

## Current Behavior 😯
Generated HTML looks like this:

```HTML
<button class="jss120 jss112" tabindex="0" type="button"></button>
```

## Steps to Reproduce 🕹

Go to any page with a Slider component and run [Lighthouse](https://chrome.google.com/webstore/detail/lighthouse/blipmdconlkpinefehnmjammfjpmpbjk?hl=en)

Link:
https://material-ui.com/lab/slider/

## Context 🔦

I want to make good, accessible web apps that hopefully work for everybody, including people using screen readers.

## Your Environment 🌎

| Tech         | Version |
|--------------|---------|
| Material-UI  | v3.9.2 (lab 3.0.0-alpha.30)  |
| React        |16.7.0|
| Browser      |Chrome 74.0.3694.0|



Issue discussion:
Thank you for reporting this.

I think we're currently using the [`slider` role](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/ARIA_Techniques/Using_the_slider_role) wrong. It should be attached to the handle (which is the button) and not the wrapper. 

I wonder what semantics should be attached the the other parts: wrapper, track etc. 
@eps1lon You're right. Shouldn't `eslint-plugin-jsx-a11y` have picked this up though?

I don't believe non-interactive elements need specific treatment.

Solution patch:
diff --git a/packages/material-ui-lab/src/Slider/Slider.js b/packages/material-ui-lab/src/Slider/Slider.js
--- a/packages/material-ui-lab/src/Slider/Slider.js
+++ b/packages/material-ui-lab/src/Slider/Slider.js
@@ -564,13 +564,7 @@ class Slider extends React.Component {
 
     return (
       <Component
-        role="slider"
         className={className}
-        aria-disabled={disabled}
-        aria-valuenow={value}
-        aria-valuemin={min}
-        aria-valuemax={max}
-        aria-orientation={vertical ? 'vertical' : 'horizontal'}
         onClick={this.handleClick}
         onMouseDown={this.handleMouseDown}
         onTouchStartCapture={this.handleTouchStart}
@@ -584,6 +578,10 @@ class Slider extends React.Component {
           <div className={trackBeforeClasses} style={inlineTrackBeforeStyles} />
           <div className={thumbWrapperClasses} style={inlineThumbStyles}>
             <ButtonBase
+              aria-valuenow={value}
+              aria-valuemin={min}
+              aria-valuemax={max}
+              aria-orientation={vertical ? 'vertical' : 'horizontal'}
               className={thumbClasses}
               disabled={disabled}
               disableRipple
@@ -592,6 +590,7 @@ class Slider extends React.Component {
               onTouchStartCapture={this.handleTouchStart}
               onTouchMove={this.handleTouchMove}
               onFocusVisible={this.handleFocus}
+              role="slider"
             >
               {ThumbIcon}
             </ButtonBase>
diff --git a/packages/material-ui/src/test-utils/findOutermostIntrinsic.js b/packages/material-ui/src/test-utils/findOutermostIntrinsic.js
--- a/packages/material-ui/src/test-utils/findOutermostIntrinsic.js
+++ b/packages/material-ui/src/test-utils/findOutermostIntrinsic.js
@@ -1,3 +1,13 @@
+/**
+ * checks if a given react wrapper wraps an intrinsic element i.e. a DOM node
+ *
+ * @param {import('enzyme').ReactWrapper} reactWrapper
+ * @returns {boolean} true if the given reactWrapper wraps an intrinsic element
+ */
+export function wrapsIntrinsicElement(reactWrapper) {
+  return typeof reactWrapper.type() === 'string';
+}
+
 /**
  * like ReactWrapper#getDOMNode() but returns a ReactWrapper
  *
@@ -5,5 +15,5 @@
  * @returns {import('enzyme').ReactWrapper} the wrapper for the outermost DOM node
  */
 export default function findOutermostIntrinsic(reactWrapper) {
-  return reactWrapper.findWhere(n => n.exists() && typeof n.type() === 'string').first();
+  return reactWrapper.findWhere(n => n.exists() && wrapsIntrinsicElement(n)).first();
 }
diff --git a/packages/material-ui/src/test-utils/index.js b/packages/material-ui/src/test-utils/index.js
--- a/packages/material-ui/src/test-utils/index.js
+++ b/packages/material-ui/src/test-utils/index.js
@@ -1,7 +1,7 @@
 export { default as createShallow } from './createShallow';
 export { default as createMount } from './createMount';
 export { default as createRender } from './createRender';
-export { default as findOutermostIntrinsic } from './findOutermostIntrinsic';
+export { default as findOutermostIntrinsic, wrapsIntrinsicElement } from './findOutermostIntrinsic';
 export { default as getClasses } from './getClasses';
 export { default as testRef } from './testRef';
 export { default as unwrap } from './unwrap';


Test patch:
diff --git a/packages/material-ui-lab/src/Slider/Slider.test.js b/packages/material-ui-lab/src/Slider/Slider.test.js
--- a/packages/material-ui-lab/src/Slider/Slider.test.js
+++ b/packages/material-ui-lab/src/Slider/Slider.test.js
@@ -1,7 +1,12 @@
 import React from 'react';
 import { spy } from 'sinon';
 import { assert } from 'chai';
-import { createMount, createShallow, getClasses } from '@material-ui/core/test-utils';
+import {
+  createMount,
+  createShallow,
+  getClasses,
+  wrapsIntrinsicElement,
+} from '@material-ui/core/test-utils';
 import Slider, { defaultValueReducer } from './Slider';
 
 function touchList(touchArray) {
@@ -20,6 +25,13 @@ describe('<Slider />', () => {
     mount = createMount();
   });
 
+  function findHandle(wrapper) {
+    // Will also match any other react component if not filtered. They won't appear in the DOM
+    // and are therefore an implementation detail. We're interested in what the user
+    // interacts with.
+    return wrapper.find('[role="slider"]').filterWhere(wrapsIntrinsicElement);
+  }
+
   it('should render a div', () => {
     const wrapper = shallow(<Slider value={0} />);
     assert.strictEqual(wrapper.name(), 'div');
@@ -195,10 +207,10 @@ describe('<Slider />', () => {
     });
 
     it('should render thumb with the disabled classes', () => {
-      const button = wrapper.find('button');
+      const handle = findHandle(wrapper);
 
-      assert.strictEqual(button.hasClass(classes.thumb), true);
-      assert.strictEqual(button.hasClass(classes.disabled), true);
+      assert.strictEqual(handle.hasClass(classes.thumb), true);
+      assert.strictEqual(handle.hasClass(classes.disabled), true);
     });
 
     it('should render tracks with the disabled classes', () => {
@@ -213,12 +225,8 @@ describe('<Slider />', () => {
       assert.strictEqual(handleChange.callCount, 0);
     });
 
-    it('should disable its thumb', () => {
-      assert.ok(wrapper.find('button').props().disabled);
-    });
-
     it('should signal that it is disabled', () => {
-      assert.ok(wrapper.find('[role="slider"]').props()['aria-disabled']);
+      assert.ok(findHandle(wrapper).props().disabled);
     });
   });
 
@@ -275,34 +283,34 @@ describe('<Slider />', () => {
 
     it('should reach right edge value', () => {
       wrapper.setProps({ value: 90 });
-      const button = wrapper.find('button');
+      const handle = findHandle(wrapper);
 
-      button.prop('onKeyDown')(moveRightEvent);
+      handle.prop('onKeyDown')(moveRightEvent);
       assert.strictEqual(wrapper.prop('value'), 100);
 
-      button.prop('onKeyDown')(moveRightEvent);
+      handle.prop('onKeyDown')(moveRightEvent);
       assert.strictEqual(wrapper.prop('value'), 108);
 
-      button.prop('onKeyDown')(moveLeftEvent);
+      handle.prop('onKeyDown')(moveLeftEvent);
       assert.strictEqual(wrapper.prop('value'), 100);
 
-      button.prop('onKeyDown')(moveLeftEvent);
+      handle.prop('onKeyDown')(moveLeftEvent);
       assert.strictEqual(wrapper.prop('value'), 90);
     });
 
     it('should reach left edge value', () => {
       wrapper.setProps({ value: 20 });
-      const button = wrapper.find('button');
-      button.prop('onKeyDown')(moveLeftEvent);
+      const handle = findHandle(wrapper);
+      handle.prop('onKeyDown')(moveLeftEvent);
       assert.strictEqual(wrapper.prop('value'), 10);
 
-      button.prop('onKeyDown')(moveLeftEvent);
+      handle.prop('onKeyDown')(moveLeftEvent);
       assert.strictEqual(wrapper.prop('value'), 6);
 
-      button.prop('onKeyDown')(moveRightEvent);
+      handle.prop('onKeyDown')(moveRightEvent);
       assert.strictEqual(wrapper.prop('value'), 10);
 
-      button.prop('onKeyDown')(moveRightEvent);
+      handle.prop('onKeyDown')(moveRightEvent);
       assert.strictEqual(wrapper.prop('value'), 20);
     });
   });