Repository: mui/material-ui (TypeScript)
Date: 2020-02-28 23:43:36+00:00
Task type: Bug Fix

Issue:
[RadioGroup] By default unnamed groups are not correctly grouped for accessibility purposes
<!-- Provide a general summary of the issue in the Title above -->

An unnamed RadioGroup will handle value changes as expected. However, the input elements of different groups will be on the same group in HTML.
<!--
  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. -->

Two unnamed groups handle the React state correctly, but the HTML will be unnamed. It makes the keyboard arrow navigation to move across groups.

## Expected Behavior 🤔

<!-- Describe what should happen. -->

Keyboard arrows should navigate only inside the group, while Tab should move between groups: https://www.w3.org/TR/wai-aria-practices-1.1/#keyboard-interaction-14

I see three possible options to handle this:
1. Auto-generate a name for unnamed groups (e.g. Reach UI, Material Angular does this). The biggest challenge of this option is how to correctly generate the id with support for SSR.
2. Emit a warning when the user doesn't provide a name
3. Make the `name` a required prop (breaking compatibility)

This is a subtle issue, since the user will not see any warning and React will handle the state as expected (MUI sets the checked state based on the RadioGroup context value). However, the end result will not handle keyboard focus correctly.

Personally, I prefer (1) since emitting a warning will left the user with the task of id generation (which is not easy, for reference see: https://github.com/reach/reach-ui/blob/master/packages/auto-id/src/index.ts)

## 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.
-->

https://codesandbox.io/s/eager-davinci-1hg9w?fontsize=14&hidenavigation=1&theme=dark

Steps:

1. Add an unnamed `RadioGroup`
2. Add another unnamed `RadioGroup`
3. Set the focus in any of the groups
4. Press arrow up or arrow down to select another option in the group.
5. When you move to the last item in the group, the keyboard will jump to the next group

## 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 🌎

<!--
  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.
-->

| Tech        | Version |
| ----------- | ------- |
| Material-UI | v4.9.4  |
| React       |  v16.12       |
| Browser     |  Chrome 79       |



Issue discussion:
@dfernandez-asapp Note that we are using (1) in

- https://github.com/mui-org/material-ui/blob/4fba0dafd30f608937efa32883d151ba01fc9681/packages/material-ui-lab/src/Rating/Rating.js#L168
- https://github.com/mui-org/material-ui/blob/4fba0dafd30f608937efa32883d151ba01fc9681/packages/material-ui-lab/src/useAutocomplete/useAutocomplete.js#L123
- https://github.com/mui-org/material-ui/blob/4fba0dafd30f608937efa32883d151ba01fc9681/packages/material-ui/src/Tooltip/Tooltip.js#L257

This issue is relevant in our context: #18172.
Many thanks for the info, I'll create a PR for this.

But I have two questions, out of curiosity:
1. I see that other frameworks like ReachUI are using a more complex logic for the ID generation, mainly because of SSR. In this approach the `setDefaultId` is called in a `useEffect`, meaning that these ids are ignored for SSR. Is there any undesired behavior for not having the ids in SSR?
2. Why store the id in a state, instead of using a ref?
1) What would you use the id for prior to hydration?
2) To rerender after hydration 
@dfernandez-asapp Do you want to work on a pull request with (1)? :) 
Yes

Solution patch:
diff --git a/docs/pages/api/radio-group.md b/docs/pages/api/radio-group.md
--- a/docs/pages/api/radio-group.md
+++ b/docs/pages/api/radio-group.md
@@ -26,7 +26,7 @@ You can learn more about the difference by [reading this guide](/guides/minimizi
 |:-----|:-----|:--------|:------------|
 | <span class="prop-name">children</span> | <span class="prop-type">node</span> |  | The content of the component. |
 | <span class="prop-name">defaultValue</span> | <span class="prop-type">any</span> |  | The default `input` element value. Use when the component is not controlled. |
-| <span class="prop-name">name</span> | <span class="prop-type">string</span> |  | The name used to reference the value of the control. |
+| <span class="prop-name">name</span> | <span class="prop-type">string</span> |  | The name used to reference the value of the control. If you don't provide this prop, it falls back to a randomly generated name. |
 | <span class="prop-name">onChange</span> | <span class="prop-type">func</span> |  | Callback fired when a radio button is selected.<br><br>**Signature:**<br>`function(event: object) => void`<br>*event:* The event source of the callback. You can pull out the new value by accessing `event.target.value` (string). |
 | <span class="prop-name">value</span> | <span class="prop-type">any</span> |  | Value of the selected radio button. The DOM API casts this to a string. |
 
diff --git a/packages/material-ui/src/RadioGroup/RadioGroup.js b/packages/material-ui/src/RadioGroup/RadioGroup.js
--- a/packages/material-ui/src/RadioGroup/RadioGroup.js
+++ b/packages/material-ui/src/RadioGroup/RadioGroup.js
@@ -6,7 +6,7 @@ import useControlled from '../utils/useControlled';
 import RadioGroupContext from './RadioGroupContext';
 
 const RadioGroup = React.forwardRef(function RadioGroup(props, ref) {
-  const { actions, children, name, value: valueProp, onChange, ...other } = props;
+  const { actions, children, name: nameProp, value: valueProp, onChange, ...other } = props;
   const rootRef = React.useRef(null);
 
   const [value, setValue] = useControlled({
@@ -43,6 +43,15 @@ const RadioGroup = React.forwardRef(function RadioGroup(props, ref) {
     }
   };
 
+  const [defaultName, setDefaultName] = React.useState();
+  const name = nameProp || defaultName;
+  React.useEffect(() => {
+    // Fallback to this default name when possible.
+    // Use the random value for client-side rendering only.
+    // We can't use it server-side.
+    setDefaultName(`mui-radiogroup-${Math.round(Math.random() * 1e5)}`);
+  }, []);
+
   return (
     <RadioGroupContext.Provider value={{ name, onChange: handleChange, value }}>
       <FormGroup role="radiogroup" ref={handleRef} {...other}>
@@ -67,6 +76,7 @@ RadioGroup.propTypes = {
   defaultValue: PropTypes.any,
   /**
    * The name used to reference the value of the control.
+   * If you don't provide this prop, it falls back to a randomly generated name.
    */
   name: PropTypes.string,
   /**


Test patch:
diff --git a/packages/material-ui/src/RadioGroup/RadioGroup.test.js b/packages/material-ui/src/RadioGroup/RadioGroup.test.js
--- a/packages/material-ui/src/RadioGroup/RadioGroup.test.js
+++ b/packages/material-ui/src/RadioGroup/RadioGroup.test.js
@@ -85,6 +85,18 @@ describe('<RadioGroup />', () => {
     assert.strictEqual(findRadio(wrapper, 'one').props().checked, true);
   });
 
+  it('should have a default name', () => {
+    const wrapper = mount(
+      <RadioGroup>
+        <Radio value="zero" />
+        <Radio value="one" />
+      </RadioGroup>,
+    );
+
+    assert.match(findRadio(wrapper, 'zero').props().name, /^mui-radiogroup-[0-9]+/);
+    assert.match(findRadio(wrapper, 'one').props().name, /^mui-radiogroup-[0-9]+/);
+  });
+
   describe('imperative focus()', () => {
     it('should focus the first non-disabled radio', () => {
       const actionsRef = React.createRef();
@@ -295,6 +307,16 @@ describe('<RadioGroup />', () => {
         setProps({ value: 'one' });
         expect(radioGroupRef.current).to.have.property('value', 'one');
       });
+
+      it('should have a default name from the instance', () => {
+        const radioGroupRef = React.createRef();
+        const { setProps } = render(<RadioGroupControlled ref={radioGroupRef} />);
+
+        expect(radioGroupRef.current.name).to.match(/^mui-radiogroup-[0-9]+/);
+
+        setProps({ name: 'anotherGroup' });
+        expect(radioGroupRef.current).to.have.property('name', 'anotherGroup');
+      });
     });
 
     describe('callbacks', () => {