-
-
Save JReinhold/247f17ea0d74c597eae0a8a44810408c to your computer and use it in GitHub Desktop.
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={} | |
/> |
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.
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).
has the Formik api changed since you wrote this
i'm not finding formik.setField that you use in onSelection