selectron-react
Version:
A select replacement component built with & for React
366 lines (336 loc) • 10.7 kB
JavaScript
import React from 'react'
import ReactDOM from 'react-dom'
import SelectTrigger from './SelectTrigger'
import SelectMultiTrigger from './SelectMultiTrigger'
import Search from './Search'
import Options from './options'
import Option from './option'
class Select extends React.Component {
constructor(props) {
super(props)
this.option = {}
this.state = {
isOpen: false,
isFocused: false,
options: props.options,
value: props.value,
highlighted: props.value || props.options[0],
searchTerm: '',
displayNoResults: false
}
const methods = [
'clickOutside',
'toggleOptions',
'onKeyDown',
'onKeyUp',
'onFocus',
'onBlur',
'onSearch',
'multiOnChange',
'updateScrollPosition',
'resize',
'toggleOverflow'
].forEach(fn => this[fn] = this[fn].bind(this))
}
componentWillMount() {
document.addEventListener('click', this.clickOutside)
window.addEventListener('resize', this.resize)
}
componentWillUnmount() {
document.removeEventListener('click', this.clickOutside)
window.removeEventListener('resize', this.resize)
}
componentWillReceiveProps(nextProps) {
const { multi, value, options } = nextProps
const currentValue = this.state.value
const currentOptions = this.state.options
let newOptions = options
if (multi && value) {
newOptions = options.filter(opt => value.findIndex(val => val.value === opt.value) < 0)
}
if (value !== currentValue) {
this.setState({
value,
options: newOptions,
highlighted: multi ? this.state.highlighted : (value || newOptions[0])
})
if ((!nextProps.onSearch && !nextProps.multi) || newOptions.length < 1 || (nextProps.onSearch && !nextProps.multi)) {
this.closeOptions()
}
} else if (options !== currentOptions) {
this.setState({
options: newOptions,
highlighted: newOptions[0],
loading: false,
displayNoResults: true
})
} else {
this.closeOptions()
}
}
componentDidUpdate(prevProps, prevState) {
if (this.state.updateScroll) {
this.setState({
updateScroll: false
})
}
}
resize() {
if (this.state.isOpen) {
this.closeOptions()
}
}
toggleOptions(e, toggle = !this.state.isOpen) {
if (e) e.preventDefault()
this.setState({
isOpen: toggle,
updateScrollPosition: toggle
})
}
closeOptions(focus = true) {
this.toggleOptions(null, false)
this.setState({ searchTerm: '' })
if (focus) {
this.focusTrigger()
} else {
this.setState({ isFocused: false })
}
}
openOptions() {
this.toggleOptions(null, true)
}
clickOutside(e) {
if (this.select.contains(e.target) || (this.options && this.options.wrapper.contains(e.target))) return
this.closeOptions(false)
}
focusTrigger() {
this.trigger.button.focus()
}
updateScrollPosition() {
const { options } = this.state
if (options.length < 1) {
return
}
const node = this[`option-${this.state.highlighted.value}`].option
const item = {
node: node,
top: node.offsetTop,
bottom: node.offsetTop + node.offsetHeight,
height: node.offsetHeight,
index: options.indexOf(this.state.highlighted)
}
const list = {
node: this.list,
height: this.list.offsetHeight,
scroll: this.list.scrollTop,
scrollHeight: this.list.scrollHeight
}
if (item.index === this.state.options.length -1) {
list.node.scrollTop = list.scrollHeight
} else if (item.bottom - list.scroll > list.height) {
list.node.scrollTop = item.top - (list.height - item.height)
} else if(item.top < list.scroll) {
list.node.scrollTop = item.top
}
}
onKeyDown(e) {
const { isOpen, highlighted, options } = this.state
switch (e.which) {
case 13: {
e.preventDefault()
break
}
case 27: {
if (isOpen) {
this.closeOptions()
}
break
}
case 38: {
if (!isOpen) {
this.openOptions()
} else if (options.length > 0) {
const currentIndex = options.map(opt => opt.value).indexOf(highlighted.value)
const nextIndex = currentIndex === 0 ? options.length - 1 : currentIndex - 1
this.setState({
highlighted: options[nextIndex],
updateScroll: true
})
}
break
}
case 40: {
if (!isOpen) {
this.openOptions()
} else if (options.length > 0) {
const currentIndex = options.map(opt => opt.value).indexOf(highlighted.value)
const nextIndex = currentIndex === options.length - 1 ? 0 : currentIndex + 1
this.setState({
highlighted: options[nextIndex],
updateScroll: true
})
}
break
}
}
}
onKeyUp(e) {
const { multi, options, onChange } = this.props
const { isOpen, highlighted } = this.state
const changeFunction = multi ? this.multiOnChange : onChange
switch (e.which) {
case 13: {
if(!isOpen) {
return false
} else if (this.state.options) {
this.setState({
options,
searchTerm: ''
})
changeFunction(this.state.highlighted)
}
}
}
}
onFocus() {
this.setState({ isFocused: true })
}
onBlur() {
this.setState({ isFocused: false }, () => {
const focused = document.activeElement
if (!this.search || ((this.search && focused !== this.search.input) && focused !== this.trigger.button)) {
this.closeOptions(false)
}
})
}
onSearch({target}) {
const { onSearch, options, multi, value } = this.props
if (onSearch) {
clearTimeout(this.ajaxTimer)
this.setState({
searchTerm: target.value,
options: [],
loading: true,
displayNoResults: false
})
this.ajaxTimer = setTimeout(() => {
onSearch(target.value)
}, 500)
return false
}
let newOptions = options
if (multi && value) {
newOptions = options.filter(opt => value.findIndex(val => val.value === opt.value) < 0)
}
newOptions = newOptions.filter(({label}) => label.toLowerCase().includes(target.value.toLowerCase()))
this.setState({
searchTerm: target.value,
options: newOptions,
highlight: newOptions[0],
displayNoResults: true
})
}
multiOnChange(value, remove = false) {
const { onChange } = this.props
const currentValue = this.props.value || []
if (!value) {
onChange(null)
return false
}
if (remove) {
const newValue = currentValue.filter(item => item.value !== value.value)
if (newValue.length < 1) {
onChange(null)
return false
}
onChange(newValue)
} else {
const { options } = this.state
currentValue.push(value)
if (options.length > 1) {
const index = options.findIndex(opt => opt.value === value.value)
const newHighlight = index + 1 > options.length - 1 ? options[index - 1] : options[index + 1]
this.setState({ highlighted: newHighlight }, () => {
onChange(currentValue.slice(0))
})
} else {
onChange(currentValue.slice(0))
}
}
}
toggleOverflow(toggle = true) {
this.setState({ overflowing: toggle })
}
render() {
const { placeholder, multi, clearable, searchable, name } = this.props
const { isOpen, isFocused, value, highlighted, options, searchTerm, loading, displayNoResults, overflowing } = this.state
const onChange = multi ? this.multiOnChange : this.props.onChange
const triggerProps = {
onChange: multi ? onChange : null,
onMouseDown: this.toggleOptions,
onKeyDown: this.onKeyDown,
onKeyUp: this.onKeyUp,
onFocus: this.onFocus,
onBlur: this.onBlur,
value: value,
placeholder: placeholder,
ref: node => { this.trigger = node }
}
const Trigger = multi ? SelectMultiTrigger : SelectTrigger
const searchProps = {
onChange: this.onSearch,
onKeyDown: this.onKeyDown,
onKeyUp: this.onKeyUp,
onFocus: this.onFocus,
onBlur: this.onBlur,
value: searchTerm
}
const classes = ['selectron']
if (isOpen) classes.push('is-open')
if (isFocused) classes.push('is-focused')
if (clearable) classes.push('is-clearable')
if (multi) classes.push('multiple')
if (value) classes.push('is-filled')
if (overflowing) classes.push('is-overflowing')
return (
<div className={ classes.join(' selectron--')} ref={node => { this.select = node }}>
<Trigger {...triggerProps} />
{ value && clearable && !loading &&
<button type="button" className="selectron__clear"
onMouseDown={(e) => {
e.preventDefault()
onChange(null)
}}>Clear</button>
}
{ loading &&
<div className="selectron__spinner"></div>
}
{ isOpen &&
<Options select={ this.select } ref={node => { this.options = node }} onMount={ this.updateScrollPosition } updateScroll={ this.state.updateScroll } isOverflowing={ overflowing } toggleOverflow={ this.toggleOverflow }>
{ searchable &&
<Search {...searchProps} ref={node => { this.search = node }}/>
}
<ul className="selectron__list" ref={node => { this.list = node }}>
{ options.length < 1 && displayNoResults &&
<li className="selectron__option selectron__option--empty">{ loading ? "Loading..." : "No results" }</li>
}
{ options.map(option => {
const isSelected = value ? option.value === value.value : false
const isHighlighted = option.value === highlighted.value
return (
<Option key={ option.value } option={ option } term={ this.state.searchTerm } onSelect={ onChange } highlighted={ isHighlighted } selected={ isSelected } onMouseEnter={() => { this.setState({ highlighted: option }) }} ref={node => { this[`option-${option.value}`] = node }} />
)
})}
</ul>
</Options>
}
{ multi ? (
<input type="hidden" name={ name } value={ value ? value.map(val => val.value ).join(',') : '' } />
) : (
<input type="hidden" name={ name } value={ value ? value.value : '' } />
)}
</div>
)
}
}
export default Select