Repository: mui/material-ui (TypeScript)
Date: 2022-01-20 09:59:24+00:00
Task type: Feature

Issue:
Possibility to have a readOnly Autocomplete
<!-- Provide a general summary of the feature in the Title above -->
It would be nice to have a readOnly prop in the Autocomplete component, so it can behave as a readOnly when I am not allowed to edit it (it doesn't mean it should be disabled).

<!--
  Thank you very much for contributing to MUI 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] I have searched the [issues](https://github.com/mui-org/material-ui/issues) of this repository and believe that this is not a duplicate.

## Summary 💡

<!-- Describe how it should work. -->
There is a readOnly prop that I can pass into the Autocomplete component, and then the component should behave as a readOnly field (I can focus on it, but the options are not opened).

## Examples 🌈
```
<Autocomplete
  options={options}
  getOptionLabel={(option) => option.name}
  renderInput={(params) => (
    <TextFieldBase
      {...params}
      inputRef={ref}
     />
  )}
  readOnly={true}
/>
```

<!--
  Provide a link to the Material Design guidelines, other implementations,
  or screenshots of the expected behavior.
-->


## Motivation 🔦

<!--
  What are you trying to accomplish? How has the lack of this feature affected you?
  Providing context helps us come up with a solution that is most useful in the real world.
-->

I'm currently working on a form that is initially readOnly, so I can't edit it (I can edit it after clicking an "Edit" button). There is an autocomplete field in the form, but I can't make it readOnly as the other fields. A readOnly behavior in my perspective is: it's not possible to open the options, but I can still focus on the field. My workaround for now is to make it disabled, however, I cannot not focus on the element, so it's looking different from other fields.



Issue discussion:
Hi, thanks for the idea. If we see there's a bigger interest in this, we'll prioritize it.
As for now, a workaround I can think of (not the most elegant one, I admit) would be to render either the Autocomplete or a TextField, depending on the `readOnly` parameter.
I found the same issue while implementing a complex form with multiple different field types. I actually asked for a solution on [StackOverflow](https://stackoverflow.com/questions/70008342/how-to-make-the-mui-autocomplete-read-only/70008491?noredirect=1#comment123788459_70008491).

The same issue also applies to the `Switch` component. All form elements should implement the same interface; allowing to disable is already possible, but not read-only.
The current solution I am using is this one :

```jsx
import React, { forwardRef, useState } from 'react';
import PropType from 'prop-types';

import Autocomplete from '@mui/material/Autocomplete';

const AutocompleteEx = forwardRef(({
   readOnly,
   renderInput,
   ...props
}, ref) => {
   const [open, setOpen] = useState(false);

   return (
      <Autocomplete ref={ ref }
         open={ open }
         onOpen={ () => !readOnly && setOpen(true) }
         onClose={ () => setOpen(false) }
         disableClearable={ readOnly }
         renderInput={ ({ inputProps, ...params }) => 
            renderInput({ ...params, inputProps: { readOnly, ...inputProps } }) 
         }
         { ...props }
      />
   );
});
AutocompleteEx.propTypes = {
   readOnly: PropType.bool,
   renderInput: PropType.func.isRequired
};
AutocompleteEx.defaultProps = {
   readOnly: false
};

export default AutocompleteEx
```

Solution patch:
diff --git a/docs/pages/api-docs/autocomplete.json b/docs/pages/api-docs/autocomplete.json
--- a/docs/pages/api-docs/autocomplete.json
+++ b/docs/pages/api-docs/autocomplete.json
@@ -71,6 +71,7 @@
     "PaperComponent": { "type": { "name": "elementType" }, "default": "Paper" },
     "PopperComponent": { "type": { "name": "elementType" }, "default": "Popper" },
     "popupIcon": { "type": { "name": "node" }, "default": "<ArrowDropDownIcon />" },
+    "readOnly": { "type": { "name": "bool" } },
     "renderGroup": { "type": { "name": "func" } },
     "renderOption": { "type": { "name": "func" } },
     "renderTags": { "type": { "name": "func" } },
diff --git a/docs/src/pages/components/autocomplete/Playground.js b/docs/src/pages/components/autocomplete/Playground.js
--- a/docs/src/pages/components/autocomplete/Playground.js
+++ b/docs/src/pages/components/autocomplete/Playground.js
@@ -148,6 +148,15 @@ export default function Playground() {
           <TextField {...params} label="selectOnFocus" variant="standard" />
         )}
       />
+      <Autocomplete
+        {...flatProps}
+        id="readOnly"
+        readOnly
+        defaultValue={flatProps.options[13]}
+        renderInput={(params) => (
+          <TextField {...params} label="readOnly" variant="standard" />
+        )}
+      />
     </Stack>
   );
 }
diff --git a/docs/src/pages/components/autocomplete/Playground.tsx b/docs/src/pages/components/autocomplete/Playground.tsx
--- a/docs/src/pages/components/autocomplete/Playground.tsx
+++ b/docs/src/pages/components/autocomplete/Playground.tsx
@@ -146,6 +146,15 @@ export default function Playground() {
           <TextField {...params} label="selectOnFocus" variant="standard" />
         )}
       />
+      <Autocomplete
+        {...flatProps}
+        id="readOnly"
+        readOnly
+        defaultValue={flatProps.options[13]}
+        renderInput={(params) => (
+          <TextField {...params} label="readOnly" variant="standard" />
+        )}
+      />
     </Stack>
   );
 }
diff --git a/docs/src/pages/components/autocomplete/Tags.js b/docs/src/pages/components/autocomplete/Tags.js
--- a/docs/src/pages/components/autocomplete/Tags.js
+++ b/docs/src/pages/components/autocomplete/Tags.js
@@ -57,6 +57,16 @@ export default function Tags() {
           />
         )}
       />
+      <Autocomplete
+        multiple
+        id="tags-readOnly"
+        options={top100Films.map((option) => option.title)}
+        defaultValue={[top100Films[12].title, top100Films[13].title]}
+        readOnly
+        renderInput={(params) => (
+          <TextField {...params} label="readOnly" placeholder="Favorites" />
+        )}
+      />
     </Stack>
   );
 }
diff --git a/docs/src/pages/components/autocomplete/Tags.tsx b/docs/src/pages/components/autocomplete/Tags.tsx
--- a/docs/src/pages/components/autocomplete/Tags.tsx
+++ b/docs/src/pages/components/autocomplete/Tags.tsx
@@ -57,6 +57,16 @@ export default function Tags() {
           />
         )}
       />
+      <Autocomplete
+        multiple
+        id="tags-readOnly"
+        options={top100Films.map((option) => option.title)}
+        defaultValue={[top100Films[12].title, top100Films[13].title]}
+        readOnly
+        renderInput={(params) => (
+          <TextField {...params} label="readOnly" placeholder="Favorites" />
+        )}
+      />
     </Stack>
   );
 }
diff --git a/docs/translations/api-docs/autocomplete/autocomplete.json b/docs/translations/api-docs/autocomplete/autocomplete.json
--- a/docs/translations/api-docs/autocomplete/autocomplete.json
+++ b/docs/translations/api-docs/autocomplete/autocomplete.json
@@ -53,6 +53,7 @@
     "PaperComponent": "The component used to render the body of the popup.",
     "PopperComponent": "The component used to position the popup.",
     "popupIcon": "The icon to display in place of the default popup icon.",
+    "readOnly": "If <code>true</code>, the component becomes readonly. It is also supported for multiple tags where the tag cannot be deleted.",
     "renderGroup": "Render the group.<br><br><strong>Signature:</strong><br><code>function(params: AutocompleteRenderGroupParams) =&gt; ReactNode</code><br><em>params:</em> The group to render.",
     "renderInput": "Render the input.<br><br><strong>Signature:</strong><br><code>function(params: object) =&gt; ReactNode</code><br>",
     "renderOption": "Render the option, use <code>getOptionLabel</code> by default.<br><br><strong>Signature:</strong><br><code>function(props: object, option: T, state: object) =&gt; ReactNode</code><br><em>props:</em> The props to apply on the li element.<br><em>option:</em> The option to render.<br><em>state:</em> The state of the component.",
diff --git a/packages/mui-base/src/AutocompleteUnstyled/useAutocomplete.js b/packages/mui-base/src/AutocompleteUnstyled/useAutocomplete.js
--- a/packages/mui-base/src/AutocompleteUnstyled/useAutocomplete.js
+++ b/packages/mui-base/src/AutocompleteUnstyled/useAutocomplete.js
@@ -101,6 +101,7 @@ export default function useAutocomplete(props) {
     open: openProp,
     openOnFocus = false,
     options,
+    readOnly = false,
     selectOnFocus = !props.freeSolo,
     value: valueProp,
   } = props;
@@ -211,7 +212,7 @@ export default function useAutocomplete(props) {
   const inputValueIsSelectedValue =
     !multiple && value != null && inputValue === getOptionLabel(value);
 
-  const popupOpen = open;
+  const popupOpen = open && !readOnly;
 
   const filteredOptions = popupOpen
     ? filterOptions(
@@ -235,7 +236,7 @@ export default function useAutocomplete(props) {
       )
     : [];
 
-  const listboxAvailable = open && filteredOptions.length > 0;
+  const listboxAvailable = open && filteredOptions.length > 0 && !readOnly;
 
   if (process.env.NODE_ENV !== 'production') {
     if (value !== null && !freeSolo && options.length > 0) {
@@ -823,7 +824,7 @@ export default function useAutocomplete(props) {
           }
           break;
         case 'Backspace':
-          if (multiple && inputValue === '' && value.length > 0) {
+          if (multiple && !readOnly && inputValue === '' && value.length > 0) {
             const index = focusedTag === -1 ? value.length - 1 : focusedTag;
             const newValue = value.slice();
             newValue.splice(index, 1);
@@ -1041,7 +1042,7 @@ export default function useAutocomplete(props) {
       key: index,
       'data-tag-index': index,
       tabIndex: -1,
-      onDelete: handleTagDelete(index),
+      ...(!readOnly && { onDelete: handleTagDelete(index) }),
     }),
     getListboxProps: () => ({
       role: 'listbox',
diff --git a/packages/mui-material/src/Autocomplete/Autocomplete.d.ts b/packages/mui-material/src/Autocomplete/Autocomplete.d.ts
--- a/packages/mui-material/src/Autocomplete/Autocomplete.d.ts
+++ b/packages/mui-material/src/Autocomplete/Autocomplete.d.ts
@@ -189,6 +189,11 @@ export interface AutocompleteProps<
    * @default <ArrowDropDownIcon />
    */
   popupIcon?: React.ReactNode;
+  /**
+   * If `true`, the component becomes readonly. It is also supported for multiple tags where the tag cannot be deleted.
+   * @default false
+   */
+  readOnly?: boolean;
   /**
    * Render the group.
    *
diff --git a/packages/mui-material/src/Autocomplete/Autocomplete.js b/packages/mui-material/src/Autocomplete/Autocomplete.js
--- a/packages/mui-material/src/Autocomplete/Autocomplete.js
+++ b/packages/mui-material/src/Autocomplete/Autocomplete.js
@@ -407,6 +407,7 @@ const Autocomplete = React.forwardRef(function Autocomplete(inProps, ref) {
     PaperComponent = Paper,
     PopperComponent = Popper,
     popupIcon = <ArrowDropDownIcon />,
+    readOnly = false,
     renderGroup: renderGroupProp,
     renderInput,
     renderOption: renderOptionProp,
@@ -439,7 +440,7 @@ const Autocomplete = React.forwardRef(function Autocomplete(inProps, ref) {
     groupedOptions,
   } = useAutocomplete({ ...props, componentName: 'Autocomplete' });
 
-  const hasClearIcon = !disableClearable && !disabled && dirty;
+  const hasClearIcon = !disableClearable && !disabled && dirty && !readOnly;
   const hasPopupIcon = (!freeSolo || forcePopupIcon === true) && forcePopupIcon !== false;
 
   const ownerState = {
@@ -573,6 +574,7 @@ const Autocomplete = React.forwardRef(function Autocomplete(inProps, ref) {
           inputProps: {
             className: clsx(classes.input),
             disabled,
+            readOnly,
             ...getInputProps(),
           },
         })}
@@ -979,6 +981,11 @@ Autocomplete.propTypes /* remove-proptypes */ = {
    * @default <ArrowDropDownIcon />
    */
   popupIcon: PropTypes.node,
+  /**
+   * If `true`, the component becomes readonly. It is also supported for multiple tags where the tag cannot be deleted.
+   * @default false
+   */
+  readOnly: PropTypes.bool,
   /**
    * Render the group.
    *


Test patch:
diff --git a/packages/mui-material/src/Autocomplete/Autocomplete.test.js b/packages/mui-material/src/Autocomplete/Autocomplete.test.js
--- a/packages/mui-material/src/Autocomplete/Autocomplete.test.js
+++ b/packages/mui-material/src/Autocomplete/Autocomplete.test.js
@@ -12,7 +12,7 @@ import {
 import { spy } from 'sinon';
 import { ThemeProvider, createTheme } from '@mui/material/styles';
 import TextField from '@mui/material/TextField';
-import Chip from '@mui/material/Chip';
+import Chip, { chipClasses } from '@mui/material/Chip';
 import Autocomplete, {
   autocompleteClasses as classes,
   createFilterOptions,
@@ -2405,4 +2405,101 @@ describe('<Autocomplete />', () => {
       expect(paperRoot).to.have.class('my-class');
     });
   });
+
+  describe('prop: readOnly', () => {
+    it('should make the input readonly', () => {
+      render(
+        <Autocomplete
+          readOnly
+          options={['one', 'two', 'three']}
+          renderInput={(params) => <TextField {...params} />}
+        />,
+      );
+      const input = screen.getByRole('textbox');
+      expect(input).to.have.attribute('readonly');
+    });
+
+    it('should not render the clear button', () => {
+      render(
+        <Autocomplete
+          readOnly
+          defaultValue="one"
+          options={['one', 'two', 'three']}
+          renderInput={(params) => <TextField {...params} />}
+        />,
+      );
+      expect(screen.queryByTitle('Clear')).to.equal(null);
+    });
+
+    it('should not apply the hasClearIcon class', () => {
+      const { container } = render(
+        <Autocomplete
+          readOnly
+          defaultValue="one"
+          options={['one', 'two', 'three']}
+          renderInput={(params) => <TextField {...params} />}
+        />,
+      );
+      expect(container.querySelector(`.${classes.root}`)).not.to.have.class(classes.hasClearIcon);
+      expect(container.querySelector(`.${classes.root}`)).to.have.class(classes.hasPopupIcon);
+    });
+
+    it('should focus on input when clicked', () => {
+      render(
+        <Autocomplete
+          readOnly
+          defaultValue="one"
+          options={['one', 'two']}
+          renderInput={(params) => <TextField {...params} />}
+        />,
+      );
+
+      const textbox = screen.getByRole('textbox');
+      fireEvent.click(textbox);
+      expect(textbox).toHaveFocus();
+
+      act(() => {
+        textbox.blur();
+      });
+      fireEvent.click(screen.queryByTitle('Open'));
+
+      expect(textbox).toHaveFocus();
+    });
+
+    it('should not open the popup', () => {
+      render(
+        <Autocomplete
+          readOnly
+          options={['one', 'two', 'three']}
+          renderInput={(params) => <TextField {...params} />}
+        />,
+      );
+      const textbox = screen.getByRole('textbox');
+      fireEvent.mouseDown(textbox);
+      expect(screen.queryByRole('listbox')).to.equal(null);
+    });
+
+    it('should not be able to delete the tag when multiple=true', () => {
+      const { container } = render(
+        <Autocomplete
+          readOnly
+          multiple
+          defaultValue={['one', 'two']}
+          options={['one', 'two', 'three']}
+          renderInput={(params) => <TextField {...params} />}
+        />,
+      );
+
+      const chip = container.querySelector(`.${chipClasses.root}`);
+      expect(chip).not.to.have.class(chipClasses.deletable);
+
+      const textbox = screen.getByRole('textbox');
+      act(() => {
+        textbox.focus();
+      });
+      expect(container.querySelectorAll(`.${chipClasses.root}`)).to.have.length(2);
+      fireEvent.keyDown(textbox, { key: 'Backspace' });
+      expect(container.querySelectorAll(`.${chipClasses.root}`)).to.have.length(2);
+    });
+  });
 });