Repository: mui/material-ui (TypeScript)
Date: 2021-04-07 08:34:09+00:00
Task type: Bug Fix

Issue:
[Rating] clearing rating is not possible with keyboard
<!-- Provide a general summary of the issue in the Title above -->
On Rating component, you can clear (set rating to 0) by selecting same rating twice using pointer.
Setting rating to 0 is not possible with keyboard usage
<!--
  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 😯
On Rating component, you can clear (set rating to 0) by selecting same rating twice using pointer.
Setting rating to 0 is not possible with keyboard usage.
<!-- Describe what happens instead of the expected behavior. -->

## Expected Behavior 🤔
<!-- Describe what should happen. -->
There is some way to clear rating using keyboard

## 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-next

  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.
-->
https://codesandbox.io/s/nhstb?file=/demo.js:224-279
Steps:

1.  Focus rating element by tabbing to it
2. Try to set rating to 0

## 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.
-->

## Your Environment 🌎

<!--
  Run `npx @material-ui/envinfo` and post the results.
  If you encounter issues with TypeScript please include the used tsconfig.
-->
<details>
  <summary>`npx @material-ui/envinfo`</summary>
  
```
  Don't forget to mention which browser you used.
  Output from `npx @material-ui/envinfo` goes here.
```
</details>



Issue discussion:
In the initial design of the component, the cleared state was only supported as an initial state. Later on, we added the "click twice to clear" interaction. I guess we could follow the direction proposed in this issue. This is what is done in https://www.w3.org/WAI/tutorials/forms/custom-controls/#a-star-rating.

As a side note, it makes me think of #25093.
I would propose the following diff:

```diff
diff --git a/packages/material-ui/src/Rating/Rating.js b/packages/material-ui/src/Rating/Rating.js
index 34d70255fb..263e633bc2 100644
--- a/packages/material-ui/src/Rating/Rating.js
+++ b/packages/material-ui/src/Rating/Rating.js
@@ -441,17 +441,18 @@ const Rating = React.forwardRef(function Rating(props, ref) {
           checked: itemValue === valueRounded,
         });
       })}
-      {!readOnly && !disabled && valueRounded == null && (
+      {!readOnly && !disabled && (
         <label className={clsx({ [classes.labelEmptyValueActive]: emptyValueFocused })}>
           <input
             value=""
             id={`${name}-empty`}
             type="radio"
             name={name}
-            defaultChecked
+            checked={valueRounded == null}
             className={classes.visuallyHidden}
             onFocus={() => setEmptyValueFocused(true)}
             onBlur={() => setEmptyValueFocused(false)}
+            onChange={handleChange}
           />
           <span className={classes.visuallyHidden}>{emptyLabelText}</span>
         </label>
```

For the test, if the available testing-library fire event API isn't enough, we can leverage the work @eps1lon did in #25405.
Ideally we'd have an API that allows for various input methods to clear the Rating. Adding an extra button is the most obvious affordance but might be considered too excessive if targetted at power-users. `Backspace` or `Del` for clearing a rating might also be an idea (just guessing here. needs UX research). If we can show both affordances in a demo that'd be perfect :+1: 
@eps1lon <kbd>Backspace</kbd>/Del sounds great. Should it replace the approach of allowing an empty radio option in the keyboard navigation (the diff that https://github.com/mui-org/material-ui/issues/25533#issuecomment-808889153 implements)? 
I'm not that familiar with the implementation and I don't think we should discuss the implementation in an issue. The problem we're trying to solve is how an affordance for clearing might look. Not how we implement it. That should be decoupled generally and discussed in the actual PR.
@eps1lon I meant, Should we allow an empty radio option in the keyboard navigation as in https://www.w3.org/WAI/tutorials/forms/custom-controls/#a-star-rating?

Solution patch:
diff --git a/packages/material-ui/src/Rating/Rating.js b/packages/material-ui/src/Rating/Rating.js
--- a/packages/material-ui/src/Rating/Rating.js
+++ b/packages/material-ui/src/Rating/Rating.js
@@ -310,7 +310,7 @@ const Rating = React.forwardRef(function Rating(inProps, ref) {
   };
 
   const handleChange = (event) => {
-    let newValue = parseFloat(event.target.value);
+    let newValue = event.target.value === '' ? null : parseFloat(event.target.value);
 
     // Give mouse priority over keyboard
     // Fix https://github.com/mui-org/material-ui/issues/22827
@@ -535,7 +535,7 @@ const Rating = React.forwardRef(function Rating(inProps, ref) {
           checked: itemValue === valueRounded,
         });
       })}
-      {!readOnly && !disabled && valueRounded == null && (
+      {!readOnly && !disabled && (
         <RatingLabel
           className={clsx(classes.label, classes.labelEmptyValue)}
           styleProps={styleProps}
@@ -546,9 +546,10 @@ const Rating = React.forwardRef(function Rating(inProps, ref) {
             id={`${name}-empty`}
             type="radio"
             name={name}
-            defaultChecked
+            checked={valueRounded == null}
             onFocus={() => setEmptyValueFocused(true)}
             onBlur={() => setEmptyValueFocused(false)}
+            onChange={handleChange}
           />
           <span className={classes.visuallyHidden}>{emptyLabelText}</span>
         </RatingLabel>


Test patch:
diff --git a/packages/material-ui/src/Rating/Rating.test.js b/packages/material-ui/src/Rating/Rating.test.js
--- a/packages/material-ui/src/Rating/Rating.test.js
+++ b/packages/material-ui/src/Rating/Rating.test.js
@@ -84,6 +84,13 @@ describe('<Rating />', () => {
     expect(checked.value).to.equal('2');
   });
 
+  it('should change the value to null', () => {
+    const handleChange = spy();
+    render(<Rating name="rating-test" onChange={handleChange} value={2} />);
+    fireEvent.click(document.querySelector('#rating-test-empty'));
+    expect(handleChange.args[0][1]).to.equal(null);
+  });
+
   it('should select the empty input if value is null', () => {
     const { container } = render(<Rating name="rating-test" value={null} />);
     const input = container.querySelector('#rating-test-empty');
diff --git a/test/e2e/fixtures/Rating/BasicRating.tsx b/test/e2e/fixtures/Rating/BasicRating.tsx
new file mode 100644
--- /dev/null
+++ b/test/e2e/fixtures/Rating/BasicRating.tsx
@@ -0,0 +1,6 @@
+import * as React from 'react';
+import Rating from '@material-ui/core/Rating';
+
+export default function BasicRating() {
+  return <Rating name="rating-test" defaultValue={1} />;
+}
diff --git a/test/e2e/index.test.ts b/test/e2e/index.test.ts
--- a/test/e2e/index.test.ts
+++ b/test/e2e/index.test.ts
@@ -98,4 +98,21 @@ describe('e2e', () => {
       expect(await page.evaluate(() => document.activeElement?.textContent)).to.equal('ok');
     });
   });
+
+  describe('<Rating />', () => {
+    it('should loop the arrow key', async () => {
+      await renderFixture('Rating/BasicRating');
+
+      await page.focus('input[name="rating-test"]:checked');
+      expect(await page.evaluate(() => document.activeElement?.getAttribute('value'))).to.equal(
+        '1',
+      );
+      await page.keyboard.press('ArrowLeft');
+      expect(await page.evaluate(() => document.activeElement?.getAttribute('value'))).to.equal('');
+      await page.keyboard.press('ArrowLeft');
+      expect(await page.evaluate(() => document.activeElement?.getAttribute('value'))).to.equal(
+        '5',
+      );
+    });
+  });
 });