Last active
February 1, 2024 15:42
-
-
Save mycolaos/9609f2ad4f16c331e0175b60bd63922e to your computer and use it in GitHub Desktop.
Creatable Autocomplete simplifying the MUI Autocomplete usage.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* 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