Skip to content

Instantly share code, notes, and snippets.

@pepjo
Created February 12, 2017 13:20
Show Gist options
  • Save pepjo/e33c1eea155a8c4000242a1326e991b2 to your computer and use it in GitHub Desktop.
Save pepjo/e33c1eea155a8c4000242a1326e991b2 to your computer and use it in GitHub Desktop.
material-ui 's AutoComplete element with working open and defaultOpen
import React, { Component, PropTypes } from 'react'
import ReactDOM from 'react-dom'
import keycode from 'keycode'
import TextField from 'material-ui/TextField'
import Menu from 'material-ui/Menu'
import MenuItem from 'material-ui/MenuItem'
import Divider from 'material-ui/Divider'
import Popover from 'material-ui/Popover/Popover'
import propTypes from 'material-ui/utils/propTypes'
function getStyles (props, context, anchorEl) {
const { fullWidth } = props
const styles = {
root: {
display: 'inline-block',
position: 'relative',
width: fullWidth ? '100%' : 256,
},
menu: {
width: '100%',
},
list: {
display: 'block',
width: fullWidth ? '100%' : 256,
},
innerDiv: {
overflow: 'hidden',
},
}
if (anchorEl && fullWidth) {
styles.popover = {
width: anchorEl.clientWidth,
}
}
return styles
}
class AutoComplete extends Component {
static propTypes = {
/**
* Location of the anchor for the auto complete.
*/
anchorOrigin: propTypes.origin,
/**
* If true, the auto complete is animated as it is toggled.
*/
animated: PropTypes.bool,
/**
* Override the default animation component used.
*/
animation: PropTypes.func,
/**
* Array of strings or nodes used to populate the list.
*/
dataSource: PropTypes.array.isRequired,
/**
* Config for objects list dataSource.
*
* @typedef {Object} dataSourceConfig
*
* @property {string} text `dataSource` element key used to find a string to be matched for search
* and shown as a `TextField` input value after choosing the result.
* @property {string} value `dataSource` element key used to find a string to be shown in search results.
*/
dataSourceConfig: PropTypes.object,
/**
* Disables focus ripple when true.
*/
disableFocusRipple: PropTypes.bool,
/**
* Override style prop for error.
*/
errorStyle: PropTypes.object,
/**
* The error content to display.
*/
errorText: PropTypes.node,
/**
* Callback function used to filter the auto complete.
*
* @param {string} searchText The text to search for within `dataSource`.
* @param {string} key `dataSource` element, or `text` property on that element if it's not a string.
* @returns {boolean} `true` indicates the auto complete list will include `key` when the input is `searchText`.
*/
filter: PropTypes.func,
/**
* The content to use for adding floating label element.
*/
floatingLabelText: PropTypes.node,
/**
* If true, the field receives the property `width: 100%`.
*/
fullWidth: PropTypes.bool,
/**
* The hint content to display.
*/
hintText: PropTypes.node,
/**
* Override style for list.
*/
listStyle: PropTypes.object,
/**
* The max number of search results to be shown.
* By default it shows all the items which matches filter.
*/
maxSearchResults: PropTypes.number,
/**
* Delay for closing time of the menu.
*/
menuCloseDelay: PropTypes.number,
/**
* Props to be passed to menu.
*/
menuProps: PropTypes.object,
/**
* Override style for menu.
*/
menuStyle: PropTypes.object,
/** @ignore */
onBlur: PropTypes.func,
/**
* Callback function fired when the menu is closed.
*/
onClose: PropTypes.func,
/** @ignore */
onFocus: PropTypes.func,
/** @ignore */
onKeyDown: PropTypes.func,
/**
* Callback function that is fired when a list item is selected, or enter is pressed in the `TextField`.
*
* @param {string} chosenRequest Either the `TextField` input value, if enter is pressed in the `TextField`,
* or the text value of the corresponding list item that was selected.
* @param {number} index The index in `dataSource` of the list item selected, or `-1` if enter is pressed in the
* `TextField`.
*/
onNewRequest: PropTypes.func,
/**
* Callback function that is fired when the user updates the `TextField`.
*
* @param {string} searchText The auto-complete's `searchText` value.
* @param {array} dataSource The auto-complete's `dataSource` array.
* @param {object} params Additional information linked the update.
*/
onUpdateInput: PropTypes.func,
/**
* Auto complete menu is open by default if true.
*/
defaultOpen: PropTypes.bool,
/**
* Auto complete menu is open if true.
*/
open: PropTypes.bool,
/**
* If true, the list item is showed when a focus event triggers.
*/
openOnFocus: PropTypes.bool,
/**
* Props to be passed to popover.
*/
popoverProps: PropTypes.object,
/**
* Text being input to auto complete.
*/
searchText: PropTypes.string,
/**
* Override the inline-styles of the root element.
*/
style: PropTypes.object,
/**
* Origin for location of target.
*/
targetOrigin: propTypes.origin,
/**
* Override the inline-styles of AutoComplete's TextField element.
*/
textFieldStyle: PropTypes.object,
}
static defaultProps = {
anchorOrigin: {
vertical: 'bottom',
horizontal: 'left',
},
animated: true,
dataSourceConfig: {
text: 'text',
value: 'value',
},
disableFocusRipple: true,
filter: (searchText, key) => searchText !== '' && key.indexOf(searchText) !== -1,
fullWidth: false,
defaultOpen: false,
open: undefined,
openOnFocus: false,
onUpdateInput: () => {},
onNewRequest: () => {},
searchText: '',
menuCloseDelay: 300,
targetOrigin: {
vertical: 'top',
horizontal: 'left',
},
animation: undefined,
dataSource: undefined,
errorStyle: undefined,
errorText: undefined,
floatingLabelText: undefined,
hintText: undefined,
listStyle: undefined,
maxSearchResults: undefined,
menuProps: undefined,
menuStyle: undefined,
onBlur: undefined,
onClose: undefined,
onFocus: undefined,
onKeyDown: undefined,
popoverProps: undefined,
style: undefined,
textFieldStyle: undefined,
}
static contextTypes = {
muiTheme: PropTypes.object.isRequired,
}
state = {
focusTextField: true,
open: false,
searchText: undefined,
}
componentWillMount () {
this.requestsList = []
this.setState({
open: this.props.defaultOpen,
searchText: this.props.searchText,
})
this.timerTouchTapCloseId = null
}
componentWillReceiveProps (nextProps) {
if (this.props.searchText !== nextProps.searchText) {
this.setState({
searchText: nextProps.searchText,
})
}
}
componentWillUnmount () {
clearTimeout(this.timerTouchTapCloseId)
clearTimeout(this.timerBlurClose)
}
close () {
this.setState({
open: false,
})
if (this.props.onClose) {
this.props.onClose()
}
}
handleRequestClose = () => {
// Only take into account the Popover clickAway when we are
// not focusing the TextField.
if (!this.state.focusTextField) {
this.close()
}
}
handleMouseDown = (event) => {
// Keep the TextField focused
event.preventDefault()
}
handleItemTouchTap = (event, child) => {
const dataSource = this.props.dataSource
const index = dataSource.findIndex((data) => (data[this.props.dataSourceConfig.value] === child.key))
const chosenRequest = dataSource[index]
const searchText = this.chosenRequestText(chosenRequest)
this.setState({
searchText,
}, () => {
this.props.onUpdateInput(searchText, this.props.dataSource, {
source: 'touchTap',
})
this.timerTouchTapCloseId = setTimeout(() => {
this.timerTouchTapCloseId = null
this.close()
this.props.onNewRequest(chosenRequest, index)
}, this.props.menuCloseDelay)
})
}
chosenRequestText = (chosenRequest) => {
if (typeof chosenRequest === 'string') {
return chosenRequest
} else {
return chosenRequest[this.props.dataSourceConfig.text]
}
}
handleEscKeyDown = () => {
this.close()
}
handleKeyDown = (event) => {
if (this.props.onKeyDown) this.props.onKeyDown(event)
let searchText
switch (keycode(event)) {
case 'enter':
this.close()
searchText = this.state.searchText
if (searchText !== '') {
this.props.onNewRequest(searchText, -1)
}
break
case 'esc':
this.close()
break
case 'down':
event.preventDefault()
this.setState({
open: true,
focusTextField: false,
})
break
default:
break
}
}
handleChange = (event) => {
const searchText = event.target.value
// Make sure that we have a new searchText.
// Fix an issue with a Cordova Webview
if (searchText === this.state.searchText) {
return
}
this.setState({
searchText,
open: true,
}, () => {
this.props.onUpdateInput(searchText, this.props.dataSource, {
source: 'change',
})
})
}
handleBlur = (event) => {
if (this.state.focusTextField && this.timerTouchTapCloseId === null) {
this.timerBlurClose = setTimeout(() => {
this.close()
}, 0)
}
if (this.props.onBlur) {
this.props.onBlur(event)
}
}
handleFocus = (event) => {
if (!this.state.open && this.props.openOnFocus) {
this.setState({
open: true,
})
}
this.setState({
focusTextField: true,
})
if (this.props.onFocus) {
this.props.onFocus(event)
}
}
blur () {
this.searchTextField.blur()
}
focus () {
this.searchTextField.focus()
}
render () {
const anchorEl = this.anchorEl
const {
anchorOrigin,
animated,
animation,
dataSource,
dataSourceConfig,
disableFocusRipple,
errorStyle,
floatingLabelText,
filter,
fullWidth,
style,
hintText,
open, // eslint-disable-line no-unused-vars
defaultOpen, // eslint-disable-line no-unused-vars
maxSearchResults,
menuCloseDelay, // eslint-disable-line no-unused-vars
textFieldStyle,
menuStyle,
menuProps,
listStyle,
targetOrigin,
onClose, // eslint-disable-line no-unused-vars
onNewRequest, // eslint-disable-line no-unused-vars
onUpdateInput, // eslint-disable-line no-unused-vars
openOnFocus, // eslint-disable-line no-unused-vars
popoverProps,
searchText: searchTextProp, // eslint-disable-line no-unused-vars
...other
} = this.props
const {
style: popoverStyle,
...popoverOther
} = popoverProps || {}
const {
searchText,
focusTextField,
} = this.state
const menuOpened = this.props.open || this.state.open
const { prepareStyles } = this.context.muiTheme
const styles = getStyles(this.props, this.context, anchorEl)
const requestsList = []
dataSource.every((item) => {
const key = item[dataSourceConfig.value]
switch (typeof item) {
case 'string':
if (filter(searchText, item, item)) {
requestsList.push({
text: item,
value: (
<MenuItem
innerDivStyle={styles.innerDiv}
value={item}
primaryText={item}
disableFocusRipple={disableFocusRipple}
key={key}
/>
),
})
}
break
case 'object':
if (item && typeof item[this.props.dataSourceConfig.text] === 'string') {
const itemText = item[this.props.dataSourceConfig.text]
if (!this.props.filter(searchText, itemText, item)) break
const itemValue = item[this.props.dataSourceConfig.value]
if (itemValue.type && (itemValue.type.muiName === MenuItem.muiName ||
itemValue.type.muiName === Divider.muiName)) {
requestsList.push({
text: itemText,
value: React.cloneElement(itemValue, {
key,
disableFocusRipple,
}),
})
} else {
requestsList.push({
text: itemText,
value: (
<MenuItem
innerDivStyle={styles.innerDiv}
value={itemValue}
primaryText={itemText}
disableFocusRipple={disableFocusRipple}
key={key}
/>),
})
}
}
break
default:
// Do nothing
}
return !(maxSearchResults && maxSearchResults > 0 && requestsList.length === maxSearchResults)
})
this.requestsList = requestsList
const menu = menuOpened && requestsList.length > 0 && (
<Menu
{...menuProps}
ref="menu" // eslint-disable-line
autoWidth
disableAutoFocus={focusTextField}
onEscKeyDown={this.handleEscKeyDown}
initiallyKeyboardFocused
onItemTouchTap={this.handleItemTouchTap}
onMouseDown={this.handleMouseDown}
style={Object.assign(styles.menu, menuStyle)}
listStyle={Object.assign(styles.list, listStyle)}
>
{requestsList.map((i) => i.value)}
</Menu>
)
return (
<div style={prepareStyles(Object.assign(styles.root, style))} >
<TextField
{...other}
ref={(searchTextFieldRef) => {
this.searchTextField = searchTextFieldRef
this.anchorEl = ReactDOM.findDOMNode(searchTextFieldRef) // eslint-disable-line
}}
autoComplete="off"
value={searchText}
onChange={this.handleChange}
onBlur={this.handleBlur}
onFocus={this.handleFocus}
onKeyDown={this.handleKeyDown}
floatingLabelText={floatingLabelText}
hintText={hintText}
fullWidth={fullWidth}
multiLine={false}
errorStyle={errorStyle}
style={textFieldStyle}
/>
<Popover
style={Object.assign({}, styles.popover, popoverStyle)}
canAutoPosition={false}
anchorOrigin={anchorOrigin}
targetOrigin={targetOrigin}
open={menuOpened}
anchorEl={anchorEl}
useLayerForClickAway={false}
onRequestClose={this.handleRequestClose}
animated={animated}
animation={animation}
{...popoverOther}
>
{menu}
</Popover>
</div>
)
}
}
AutoComplete.levenshteinDistance = (searchText, key) => {
const current = []
let prev
let value
for (let i = 0; i <= key.length; i++) {
for (let j = 0; j <= searchText.length; j++) {
if (i && j) {
if (searchText.charAt(j - 1) === key.charAt(i - 1)) value = prev
else value = Math.min(current[j], current[j - 1], prev) + 1
} else {
value = i + j
}
prev = current[j]
current[j] = value
}
}
return current.pop()
}
AutoComplete.noFilter = () => true
AutoComplete.defaultFilter = AutoComplete.caseSensitiveFilter = (searchText, key) => (
searchText !== '' && key.indexOf(searchText) !== -1
)
AutoComplete.caseInsensitiveFilter = (searchText, key) => (
key.toLowerCase().indexOf(searchText.toLowerCase()) !== -1
)
AutoComplete.levenshteinDistanceFilter = (distanceLessThan) => {
if (distanceLessThan === undefined) {
return AutoComplete.levenshteinDistance
} else if (typeof distanceLessThan !== 'number') {
throw 'Error: AutoComplete.levenshteinDistanceFilter is a filter generator, not a filter!'
}
return (s, k) => AutoComplete.levenshteinDistance(s, k) < distanceLessThan
}
AutoComplete.fuzzyFilter = (searchText, key) => {
const compareString = key.toLowerCase()
searchText = searchText.toLowerCase()
let searchTextIndex = 0
for (let index = 0; index < key.length; index++) {
if (compareString[index] === searchText[searchTextIndex]) {
searchTextIndex += 1
}
}
return searchTextIndex === searchText.length
}
AutoComplete.Item = MenuItem
AutoComplete.Divider = Divider
export default AutoComplete
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment