Skip to content

Instantly share code, notes, and snippets.

@JReinhold
Last active January 10, 2020 03:21
Show Gist options
  • Save JReinhold/247f17ea0d74c597eae0a8a44810408c to your computer and use it in GitHub Desktop.
Save JReinhold/247f17ea0d74c597eae0a8a44810408c to your computer and use it in GitHub Desktop.
React Combobox aka. Auto Suggest field combining Formik, Material-UI and Downshift
import * as React from 'react';
import { StandardTextFieldProps } from '@material-ui/core/TextField/TextField';
import Paper from '@material-ui/core/Paper/Paper';
import MenuItem from '@material-ui/core/MenuItem/MenuItem';
import InputAdornment from '@material-ui/core/InputAdornment/InputAdornment';
import IconButton from '@material-ui/core/IconButton/IconButton';
import ArrowDropDown from '@material-ui/icons/ArrowDropDown';
import ArrowDropUp from '@material-ui/icons/ArrowDropUp';
import { TextField } from 'formik-material-ui';
import { Field, FieldProps, FormikProps } from 'formik';
import Downshift, { DownshiftProps } from 'downshift';
import { css } from 'emotion';
/**
* Generic TextField with auto-suggestions (using Downshift)
*
* Required Props:
* - label: The label on the input field
* - name: The name of the field, corresponding to the name given to the Formik element
* - suggestions: one of:
* - an array containing static suggestions
* - a function that returns the list of suggestions, sync or async.
* The function will be called on componentDidMount, onFocus, and possibly more.
*
*
* Optional Props:
* - onSelection: a function that gets the new selection when it changes
* - className: class applied to the root div containing the text field and dropdown
* - itemToString: a function that turns the generic item into a string shown to the user
* eg. item => item.text
* - itemToKey: Same as itemToString, but used to get a unique key for each suggestion
* eg. item => item.id
* - textFieldProps: any props passed directly to the MUI Textfield
* - downshiftProps: any props passed directly to the Downshift element
*/
const defaultItemToString = (item) => {
if (item === null) {
return '';
}
if (typeof item !== 'string' && typeof item !== 'number') {
throw Error(`An item in a Combobox was not a string or a number but still used the defaultItemToString. This is not allowed, please supply a itemToString and itemToKey prop to the Combobox
item: ${JSON.stringify(item, null, 2)}`);
}
return item + ''; // convert number to string - does not affect strings
};
export class Combobox extends React.Component {
static defaultProps = {
itemToString: defaultItemToString,
itemToKey: defaultItemToString,
};
state = {
suggestions: [],
};
componentDidMount() {
this.getSuggestions();
}
async getSuggestions() {
const { suggestions } = this.props;
if (Array.isArray(suggestions)) {
this.setState({ suggestions });
return;
}
const suggestionsResult = await suggestions();
this.setState({ suggestions: suggestionsResult });
}
filterItem = (inputValue, item, allItems) => {
const itemString = this.props.itemToString(item);
return !inputValue || itemString.toLowerCase().includes(inputValue.toLowerCase());
};
onSelection = (field, form, item) => {
form.setFieldValue(field.name, item);
this.props.onSelection && this.props.onSelection(item);
};
onFocus = async () => {
await this.getSuggestions();
};
buildFieldProps = (field) => {
return {
...field,
value: this.props.itemToString(field.value),
};
};
render() {
return (
<Field name={this.props.name}>
{({ field, form }) => (
<Downshift
itemToString={this.props.itemToString}
{...this.props.downshiftProps}
onChange={selectedItem => this.onSelection(field, form, selectedItem)}
selectedItem={field.value}
>
{({
getInputProps,
getItemProps,
getLabelProps,
getMenuProps,
isOpen,
highlightedIndex,
openMenu,
closeMenu,
inputValue,
}) => {
// merge InputProps with endAdornment, InputProps from props and getInputProps from Downshift
const InputProps = {
endAdornment: (
<InputAdornment position="end">
<IconButton
disabled={form.isSubmitting}
onClick={() => (isOpen ? closeMenu() : openMenu())}
tabIndex={-1}
>
{isOpen ? <ArrowDropUp/> : <ArrowDropDown/>}
</IconButton>
</InputAdornment>
),
...(this.props.textFieldProps && this.props.textFieldProps.InputProps),
...getInputProps({ onFocus: this.onFocus, onBlur: field.onBlur }),
};
return (
<div className={css(container, this.props.className)}>
<TextField
label={this.props.label}
fullWidth
{...this.props.textFieldProps}
InputProps={InputProps}
field={this.buildFieldProps(field)}
form={form}
/>
{isOpen && (
<Paper className={suggestionsList} {...getMenuProps()}>
{this.state.suggestions
.filter((suggestion, index, allSuggestions) =>
this.filterItem(inputValue, suggestion, allSuggestions),
)
.map((suggestion, index) => {
const isHighlighted = highlightedIndex === index;
const itemProps = getItemProps({
index,
item: suggestion,
selected: isHighlighted,
});
return (
<MenuItem {...itemProps} key={this.props.itemToKey(suggestion)}>
{this.props.itemToString(suggestion)}
</MenuItem>
);
})}
</Paper>
)}
</div>
);
}}
</Downshift>
)}
</Field>
);
}
}
const suggestionsList = css({
position: 'absolute',
zIndex: 1,
marginTop: '0.5em',
overflow: 'auto',
maxHeight: 'calc((24px + 22px) * 5)', // hardcode max height to 5 items - MenuItems are 24px high with 11px padding top and bottom
});
const container = css({
position: 'relative',
});
/* THIS IS IDENTICAL TO THE ONE ABOVE, EXCEPT THIS CONTAINS TYPESCRIPT TYPES */
import * as React from 'react';
import { StandardTextFieldProps } from '@material-ui/core/TextField/TextField';
import Paper from '@material-ui/core/Paper/Paper';
import MenuItem from '@material-ui/core/MenuItem/MenuItem';
import InputAdornment from '@material-ui/core/InputAdornment/InputAdornment';
import IconButton from '@material-ui/core/IconButton/IconButton';
import ArrowDropDown from '@material-ui/icons/ArrowDropDown';
import ArrowDropUp from '@material-ui/icons/ArrowDropUp';
import { TextField } from 'formik-material-ui';
import { Field, FieldProps, FormikProps } from 'formik';
import Downshift, { DownshiftProps } from 'downshift';
import { css } from 'emotion';
/**
* Generic TextField with auto-suggestions (using Downshift)
*
* Required Props:
* - label: The label on the input field
* - name: The name of the field, corresponding to the name given to the Formik element
* - suggestions: one of:
* - an array containing static suggestions
* - a function that returns the list of suggestions, sync or async.
* The function will be called on componentDidMount, onFocus, and possibly more.
*
*
* Optional Props:
* - onSelection: a function that gets the new selection when it changes
* - className: class applied to the root div containing the text field and dropdown
* - itemToString: a function that turns the generic item into a string shown to the user
* eg. item => item.text
* - itemToKey: Same as itemToString, but used to get a unique key for each suggestion
* eg. item => item.id
* - textFieldProps: any props passed directly to the MUI Textfield
* - downshiftProps: any props passed directly to the Downshift element
*/
interface Props<I> {
label: string;
name: string;
suggestions: I[] | (() => I[] | Promise<I[]>);
itemToString: (item: I | null) => string;
itemToKey: (item: I | null) => number | string;
onSelection?: (item: I) => void;
className?: string;
textFieldProps?: StandardTextFieldProps;
downshiftProps?: DownshiftProps<I>;
}
interface State<I> {
suggestions: I[];
}
type FormikField<Value> = {
onChange: (e: React.ChangeEvent) => void;
onBlur: (e: React.ChangeEvent) => void;
value: Value | string;
name: string;
};
/* tslint:disable-next-line:no-any */
const defaultItemToString = (item: any) => {
if (item === null) {
return '';
}
if (typeof item !== 'string' && typeof item !== 'number') {
throw Error(`An item in a Combobox was not a string or a number but still used the defaultItemToString. This is not allowed, please supply a itemToString and itemToKey prop to the Combobox
item: ${JSON.stringify(item, null, 2)}`);
}
return item + ''; // convert number to string - does not affect strings
};
export class Combobox<Item> extends React.Component<Props<Item>, State<Item>> {
static defaultProps = {
itemToString: defaultItemToString,
itemToKey: defaultItemToString,
};
state: State<Item> = {
suggestions: [],
};
componentDidMount() {
this.getSuggestions();
}
async getSuggestions() {
const { suggestions } = this.props;
if (Array.isArray(suggestions)) {
this.setState({ suggestions });
return;
}
const suggestionsResult = await suggestions();
this.setState({ suggestions: suggestionsResult });
}
filterItem = (inputValue: string | null, item: Item, allItems: Item[]): boolean => {
const itemString = this.props.itemToString(item);
return !inputValue || itemString.toLowerCase().includes(inputValue.toLowerCase());
};
onSelection = (field: FormikField<Item>, form: FormikProps<Item>, item: Item) => {
form.setFieldValue(field.name, item);
this.props.onSelection && this.props.onSelection(item);
};
onFocus = async () => {
await this.getSuggestions();
};
buildFieldProps = (field: FormikField<Item>): FormikField<Item> => {
return {
...field,
value: this.props.itemToString(field.value as Item),
};
};
render() {
return (
<Field name={this.props.name}>
{({ field, form }: FieldProps<Item>) => (
<Downshift
itemToString={this.props.itemToString}
{...this.props.downshiftProps}
onChange={selectedItem => this.onSelection(field, form, selectedItem)}
selectedItem={field.value}
>
{({
getInputProps,
getItemProps,
getLabelProps,
getMenuProps,
isOpen,
highlightedIndex,
openMenu,
closeMenu,
inputValue,
}) => {
// merge InputProps with endAdornment, InputProps from props and getInputProps from Downshift
const InputProps = {
endAdornment: (
<InputAdornment position="end">
<IconButton
disabled={form.isSubmitting}
onClick={() => (isOpen ? closeMenu() : openMenu())}
tabIndex={-1}
>
{isOpen ? <ArrowDropUp/> : <ArrowDropDown/>}
</IconButton>
</InputAdornment>
),
...(this.props.textFieldProps && this.props.textFieldProps.InputProps),
...getInputProps({ onFocus: this.onFocus, onBlur: field.onBlur }),
};
return (
<div className={css(container, this.props.className)}>
{/*
// @ts-ignore */}
<TextField
label={this.props.label}
fullWidth
{...this.props.textFieldProps}
InputProps={InputProps}
field={this.buildFieldProps(field)}
form={form}
/>
{isOpen && (
<Paper className={suggestionsList} {...getMenuProps()}>
{this.state.suggestions
.filter((suggestion, index, allSuggestions) =>
this.filterItem(inputValue, suggestion, allSuggestions),
)
.map((suggestion, index) => {
const isHighlighted = highlightedIndex === index;
const itemProps = getItemProps({
index,
item: suggestion,
selected: isHighlighted,
});
return (
<MenuItem {...itemProps} key={this.props.itemToKey(suggestion)}>
{this.props.itemToString(suggestion)}
</MenuItem>
);
})}
</Paper>
)}
</div>
);
}}
</Downshift>
)}
</Field>
);
}
}
const suggestionsList = css({
position: 'absolute',
zIndex: 1,
marginTop: '0.5em',
overflow: 'auto',
maxHeight: 'calc((24px + 22px) * 5)', // hardcode max height to 5 items - MenuItems are 24px high with 11px padding top and bottom
});
const container = css({
position: 'relative',
});
import { Combobox } from './combobox';
<Combobox
/* --- REQUIRED ---*/
/* label for the input */
label="Address"
/* name of the formik field */
name="pickUpAdress"
/* array of downshift suggestions,
or an (a)sync function returning suggestions */
suggestions={getAddressesFromBackend}
/* --- OPTIONAL ---*/
/* classes to give the root div */
className="address-combo"
/* function that gets the new selection when it changes */
onSelection={selectedItem => formik.setFieldValue('zip', selectedItem.zip)}
/* function that turns the generic item into a string shown to the user */
itemToString={item => item.text}
/* function used to get an uniqie id for each item in the suggestion list */
itemToKey={item => item.id}
/* any additional props to pass to Downshift */
downshiftProps={}
/* any additional props to pass to the MUI TextField */
textFieldProps={}
/>
@JReinhold
Copy link
Author

has the Formik api changed since you wrote this
i'm not finding formik.setField that you use in onSelection

Maybe, or I might have made a typo. Anyways, I believe the function setFieldValue from the Formik render prop is the one to use. See https://jaredpalmer.com/formik/docs/api/formik#setfieldvalue-field-string-value-any-shouldvalidate-boolean-void

I've updated the example.

@tad3j
Copy link

tad3j commented Nov 27, 2019

Thanks for sharing this example. One thing I'm strugling with is clearing the text field when user clears all characters. Currently last selected value pops up when user leaves the field. Any suggestion how to clear it?

I did another small change by adding InputLabelProps={{shrink: !!inputValue}} to TextField, which fixes a bug where material-ui label is covering selected input value (label needs to be moved up even if input is not selected).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment