Skip to content

Instantly share code, notes, and snippets.

@mycolaos
Last active February 1, 2024 15:42
Show Gist options
  • Save mycolaos/9609f2ad4f16c331e0175b60bd63922e to your computer and use it in GitHub Desktop.
Save mycolaos/9609f2ad4f16c331e0175b60bd63922e to your computer and use it in GitHub Desktop.
Creatable Autocomplete simplifying the MUI Autocomplete usage.
/**
* This is a CreatableAutocomplete component that can be used to create new
* options using MUI's Autocomplete component.
*
* Motivation: the MUI interface for creatable autocomplete is complex and hard
* to follow, this component simplifies the interface by separating the event of
* selecting an option from the event of creating a new option.
*
* Usage copy-paste it and use it like this:
*
* ```tsx
<CreatableAutocomplete
data={gamesUrls}
getOptionLabel={getOptionLabel}
onCreate={handleSelectNew}
onSelect={handleSelectExistent}
renderInput={renderInput}
/>
```
*
* What can be changed:
* 1. Props can be extended to include more MUI Autocomplete props if
* necessary.
* 2. The `renderOption` prop can be provided to customize the rendering of the
* options in the list, but make sure to use the `_getOptionLabel` function
* to get the right label for existent options and new options.
*
*/
// Next.js option, can remove this if not using Next.js.
'use client';
import Autocomplete, {
AutocompleteRenderInputParams,
createFilterOptions,
} from '@mui/material/Autocomplete';
import { FilterOptionsState } from '@mui/material';
import React from 'react';
// MUI's filter function.
const filter: <T>(
options: (T | NewOption)[],
state: FilterOptionsState<T | NewOption>
) => (T | NewOption)[] = createFilterOptions();
// New option created by the user. Format is arbitrary, but it must differ from
// the format of the other options. A more robust solution would be to use a a
// Symbol to identify this option.
type NewOption = {
inputValue: string;
optionLabel: string;
};
// ? Props can be extended to include more MUI Autocomplete props if necessary.
// ? I just included the minimum required to have a creatable autocomplete.
export type CreatableAutocompleteProps<T> = {
// The list of options.
data: T[];
// Callback when the user selects an option or clears the input.
onSelect: (value: T | null) => void;
// Callback when the user creates a new option.
onCreate: (value: string) => void;
// Callback to render the input, required by MUI when using `freeSolo`.
renderInput: (props: AutocompleteRenderInputParams) => JSX.Element;
// Custom label accessor, if not provided, it will use the default MUI label
// accessor logic, i.e. string or object with a `label` property.
getOptionLabel?: (option: T) => string;
// Whether to ignore case when comparing the input value with the options for
// a new option suggestion. Defaults to `true`.
ignoreCase?: boolean;
};
export const CreatableAutocomplete = <T extends Object>({
data,
onSelect,
onCreate,
renderInput,
getOptionLabel,
ignoreCase = true,
}: CreatableAutocompleteProps<T>) => {
// Helper function to get the label of an option which is rendered in the
// `input`. For the options in the list, the `renderOption` prop is used.
const _getOptionLabel = React.useCallback(
(option: T | NewOption | string): string => {
// For example, when clearing the input.
if (option === null || option === undefined) {
return '';
}
// String when hitting enter on the keyboard.
if (typeof option === 'string') {
return option;
}
// New option created by the user.
if ('inputValue' in option) {
return option.inputValue;
}
// Custom label accessor.
if (getOptionLabel) {
return getOptionLabel(option as T);
}
// Replicating default MUI label accessor.
if ('label' in option) {
return option.label as string;
}
// Ideally, this should never happen, if happens find out why and handle
// it.
throw new Error(
'CreatableAutocomplete: Invalid option, please provide a `getOptionLabel` function.'
);
},
[getOptionLabel]
);
// Render the option in the list and it has it's own logic to get the label,
// because the new option has it's own format.
//
// ? If you want to provide a `renderOption` prop, make sure to use this
// ? function to get the label (but ignore the jsx part).
const _renderOption = React.useCallback(
(props: any, option: T | NewOption) => {
// Get the label of the an existent option or suggests the creation of a
// new option.
const label =
(option as NewOption)?.optionLabel || _getOptionLabel(option);
return (
<li {...props} key={label}>
{label}
</li>
);
},
[_getOptionLabel]
);
// * This is the core function simplifying the handling of creatable by
// * separating the event of selecting an option from the event of creating a
// * new option.
const _handleChange = React.useCallback(
(event: any, selectedValue: any) => {
// Get the string value of the selected option for convenience.
const stringValue =
typeof selectedValue === 'string'
? selectedValue
: selectedValue?.inputValue || _getOptionLabel(selectedValue);
// Check if the value already exists in the list.
const existentValue = data.find((option) => {
if (ignoreCase) {
return (
_getOptionLabel(option)?.toLowerCase() === stringValue.toLowerCase()
);
}
return _getOptionLabel(option) === stringValue;
});
// Call the appropriate callback.
if (existentValue) {
onSelect(existentValue);
} else if (!stringValue) {
// The user cleared the input.
onSelect(null);
} else {
onCreate(stringValue);
}
},
[onCreate, onSelect, data, _getOptionLabel, ignoreCase]
);
// Filter options suggested by the autocomplete, add a new option if the
// provided value doesn't match any of them.
const _filterOptions = React.useCallback(
(options: (T | NewOption)[], state: FilterOptionsState<T | NewOption>) => {
// Autocomplete's own filter, `filtered` it's what is shown in the list.
const filtered = filter(options, state);
// Check if the value already exists in the list.
const isNewOption =
state.inputValue &&
!filtered.find((option) => {
if (ignoreCase) {
return (
_getOptionLabel(option)?.toLowerCase() ===
state.inputValue.toLowerCase()
);
}
return _getOptionLabel(option) === state.inputValue;
});
// Suggest the creation of a new value.
if (isNewOption) {
// Add this option to the list.
filtered.push({
inputValue: state.inputValue,
optionLabel: `Add "${state.inputValue}"`,
});
}
return filtered;
},
[_getOptionLabel, ignoreCase]
);
return (
<Autocomplete
freeSolo
onChange={_handleChange}
filterOptions={_filterOptions}
options={data}
getOptionLabel={_getOptionLabel}
selectOnFocus
clearOnBlur
handleHomeEndKeys
renderInput={renderInput}
renderOption={_renderOption}
/>
);
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment