Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[base-ui][material-ui][Autocomplete] Better support for uncontrolled autocomplete in HTML form submissions #43544

Open
thomasmarr opened this issue Aug 31, 2024 · 2 comments
Labels
component: autocomplete This is the name of the generic UI component, not the React module! new feature New feature or request package: base-ui Specific to @mui/base package: material-ui Specific to @mui/material

Comments

@thomasmarr
Copy link

thomasmarr commented Aug 31, 2024

Summary

Autocomplete components should work in a regular html form submission, by accepting a name prop and adding <input type="hidden" name={nameProp} value={value}/> elements, when the value prop is not being controlled.

If the multiple prop is true, then a list of hidden input elements should be added using field name array syntax (i.e. name={`${nameProp}[]`}))

Docs should note that when using in uncontrolled mode, the name prop should be added to the autocomplete component, and not manually added to the element returned by renderInput (usually a TextField), to prevent duplicate entries in the form submission.

Ideally, there ought to be a mechanism to specify what the value of the hidden input should be, based on the selected option(s). For example in an autocomplete I may want to show a list of movie names, concatenated with the year. When the user selects one, I likely want to send the movie id in the form submission, not the option label.

Currently, I can create the hidden inputs manually fairly easily if it is a multi select, by adding these props:

  multiple
  renderTags={(selectedOptions, getTagProps) =>
    selectedOptions.map((value, index) => (
      <React.Fragment key={value.id}>
        <Chip {...getTagProps({ index })} label={`${value.title}, ${value.year}`} />
        <input type="hidden" name={`movie_id[]`} value={value.id} />
      </React.Fragment>
      )
    )
  }

Or if it is a single select, and the value I want as part of the form submission is the same as the option label, I can just add the name prop to the TextField in renderInput and I don't need a hidden input:

  renderInput={(params) => (
    <TextField
      {...params}
      name="movie"
      label="Movie"
   />
  )}

However, if it's a single select and the value I want as part of the form submission is not equal to the option label, I need to add the hidden input field to the renderInput prop, and ensure the field name is attached to the hidden input, not the TextField input. Since the selected option is not available in the params arg of renderInput, the only thing I can do is a reverse lookup to find the id based on the option label (and hope there are not two movies with the same title and year in my dataset).

  renderInput={(params) => (
     <>
      <TextField
        {...params}
        label="movie"
      />
      <input
        type="hidden"
        name="movie_id"
        value={getMovieId(params.inputProps.value) ?? ""}
      />
    </>
  )}

This is the only case where as far as I can tell with the current API it is impossible to achieve something satisfactory in user-land, and still use an uncontrolled component. If the renderInput callback was provided the selected option as a second arg, we would at least be able to cover this base.

For completeness I note that if you use state, you can make even that last use-case work. However enabling the Autocomplete component to operate as an uncontrolled component as part of a form is a big enough win for simplicity for me to feel this feature request is warranted, (in many cases is has positive performance implications too).

Examples

/*
 * This one renders a hidden input with initial value="" and name="movie_id"
 * When the user selects an option, the value is updated to getOptionValue(option)
 */ 
const MyComponent = () => {
  const options = useGetOptions()
  return <Autocomplete
    name="movie_id"
    options={options}
    getOptionLabel={option =>`${option.title}, ${option.year}`}
    getOptionValue={option => option.id}
    renderInput={params => (
      <TextField
        {...params}
        label="Movie"
      />
    )}
  />
}

/*
 * This one renders one hidden input for each selected option with value=getOptionValue(option) and name="movie_id[]"
 * When the user selects an option, the value is updated to getOptionValue(option)
 */ 
const MyMultiSelectComponent = () => {
  const options = useGetOptions()
  return <Autocomplete
    name="movie_id"
    options={options}
    multiple
    getOptionLabel={option =>`${option.title}, ${option.year}`}
    getOptionValue={option => option.id}
    renderInput={params => (
      <TextField
        {...params}
        label="Movie"
      />
    )}
  />
}

Motivation

With React Router data router patterns, (or Remix) we are able to simplify some aspects of writing forms in React apps by using the normal HTML form pattern. With autocomplete components this is tricky.

Search keywords: uncontrolled autocomplete

@thomasmarr thomasmarr added the status: waiting for maintainer These issues haven't been looked at yet by a maintainer label Aug 31, 2024
@zannager zannager added the component: autocomplete This is the name of the generic UI component, not the React module! label Sep 2, 2024
@ZeeshanTamboli ZeeshanTamboli changed the title Better support for uncontrolled autocomplete in HTML form submissions [material-ui][Autocomplete] Better support for uncontrolled autocomplete in HTML form submissions Sep 3, 2024
@ZeeshanTamboli ZeeshanTamboli added the package: material-ui Specific to @mui/material label Sep 3, 2024
@ZeeshanTamboli
Copy link
Member

Thanks for opening an issue. This is somewhat similar to #42988, but that one deals with multiple options using React state.

Providing the selected option as a second argument in the renderInput callback doesn’t seem right since it goes against the purpose of the API. If you want to submit a value different from the option label without using React state, you can try using a ref:

import React from 'react';
import { Autocomplete, TextField } from '@mui/material';

const movies = [
  { id: 1, title: 'Inception', year: 2010 },
  { id: 2, title: 'Interstellar', year: 2014 },
  { id: 3, title: 'The Dark Knight', year: 2008 },
];

export default function MovieAutocomplete() {
  const hiddenInputRef = React.useRef(null);

  const handleChange = (event, newValue) => {
    if (hiddenInputRef.current) {
      hiddenInputRef.current.value = newValue ? newValue.id : '';
    }
  };

  return (
    <form action="/your-form-endpoint" method="POST">
      <Autocomplete
        options={movies}
        getOptionLabel={(option) => `${option.title} (${option.year})`}
        onChange={handleChange}
        renderInput={(params) => (
          <>
            <TextField
              {...params}
              label="Select a movie"
              variant="outlined"
            />
            <input
              type="hidden"
              name="movie_id"
              ref={hiddenInputRef}
            />
          </>
        )}
      />
      <button type="submit">Submit</button>
    </form>
  );
}

This should also work if you have duplicate options, but it's a workaround. We might consider adding more uncontrolled Autocomplete features, possibly in Base UI. Another one related to uncontrolled Autocomplete within a form is #40252. I'll mark this as a new feature request.

@ZeeshanTamboli ZeeshanTamboli changed the title [material-ui][Autocomplete] Better support for uncontrolled autocomplete in HTML form submissions [base-ui][material-ui][Autocomplete] Better support for uncontrolled autocomplete in HTML form submissions Sep 3, 2024
@ZeeshanTamboli ZeeshanTamboli added new feature New feature or request package: base-ui Specific to @mui/base and removed status: waiting for maintainer These issues haven't been looked at yet by a maintainer labels Sep 3, 2024
@thomasmarr
Copy link
Author

Agree that #42988 is very similar. @ZeeshanTamboli the solution you suggested in that issue uses state, but I notice that the comments and the example code provided by the issue reporter do not.

I'm aware I can use a ref in the way you describe but, (like the other issue reporter), was hoping that MUI would provide a simple form-compatible API out of the box. I think it makes a lot of sense for the library to do that.

If it's any help to others facing the same issue, I'm running with the below component for now. It has some rough edges but it's working for our needs at the moment. If anyone picks it up and enhances it any further I welcome you to share that code here.

(I still hope that the library will provide this behavior natively, though.)

import {
  AutocompleteOwnerState,
  AutocompleteRenderGetTagProps,
  Chip,
  ChipTypeMap,
  Autocomplete as MuiAutocomplete,
  AutocompleteProps as MuiAutocompleteProps,
} from "@mui/material";
import React from "react";

type AutocompleteProps<
  Value,
  Multiple extends boolean | undefined,
  DisableClearable extends boolean | undefined,
  FreeSolo extends boolean | undefined,
  ChipComponent extends React.ElementType = ChipTypeMap["defaultComponent"],
> = MuiAutocompleteProps<
  Value,
  Multiple,
  DisableClearable,
  FreeSolo,
  ChipComponent
> & {
  name?: string;
  getInputValue?: (option: Value) => string | number;
  findInputValue?: (
    optionLabel?: string | number | readonly string[],
  ) => string | number | undefined;
};

const defaultRenderTags = <
  Value,
  Multiple extends boolean | undefined,
  DisableClearable extends boolean | undefined,
  FreeSolo extends boolean | undefined,
  ChipComponent extends React.ElementType = ChipTypeMap["defaultComponent"],
>(
  value: Value[],
  getTagProps: AutocompleteRenderGetTagProps,
  ownerState: AutocompleteOwnerState<
    Value,
    Multiple,
    DisableClearable,
    FreeSolo,
    ChipComponent
  >,
) =>
  value.map((option, index) => (
    <Chip
      {...getTagProps({ index })}
      label={ownerState.getOptionLabel?.(option) ?? option}
    />
  ));

const Autocomplete = <
  Value,
  Multiple extends boolean | undefined,
  DisableClearable extends boolean | undefined,
  FreeSolo extends boolean | undefined = false,
  ChipComponent extends React.ElementType = ChipTypeMap["defaultComponent"],
>(
  props: AutocompleteProps<
    Value,
    Multiple,
    DisableClearable,
    FreeSolo,
    ChipComponent
  >,
) => {
  const { name, getInputValue, findInputValue, ...muiProps } = props;
  if (muiProps.value) {
    return <MuiAutocomplete {...muiProps} />;
  }
  if (muiProps.multiple) {
    return (
      <MuiAutocomplete
        {...muiProps}
        renderTags={(value, getTagProps, ownerState) => {
          const renderTags = props.renderTags || defaultRenderTags;
          return (
            <>
              {renderTags(value, getTagProps, ownerState)}
              {value.map((option, index) => {
                const { key } = getTagProps({ index });
                const value =
                  getInputValue?.(option) ??
                  muiProps.getOptionLabel?.(option) ??
                  String(option);
                return (
                  <input
                    key={key}
                    type="hidden"
                    name={`${name ?? ""}[]`}
                    value={value}
                  />
                );
              })}
            </>
          );
        }}
      />
    );
  }
  return (
    <MuiAutocomplete
      {...muiProps}
      renderInput={(params) => {
        return (
          <>
            {muiProps.renderInput(params)}
            <input
              type="hidden"
              name={name ?? ""}
              value={
                findInputValue?.(params.inputProps.value) ??
                params.inputProps.value
              }
            />
          </>
        );
      }}
    />
  );
};

export default Autocomplete;

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
component: autocomplete This is the name of the generic UI component, not the React module! new feature New feature or request package: base-ui Specific to @mui/base package: material-ui Specific to @mui/material
Projects
None yet
Development

No branches or pull requests

4 participants