ldx-widgets
Version:
widgets
270 lines (217 loc) • 7.86 kB
text/coffeescript
React = require 'react'
SearchInput = React.createFactory(require './search_input')
TextInput = React.createFactory(require './text_input')
{UP_ARROW, DOWN_ARROW, ENTER, PAGE_UP, PAGE_DOWN} = require('../constants/keyboard')
{div, ul} = React.DOM
###
Input Type Ahead Props
@results - REQUIRED - Array
Array of items that are printed in the results list
@value - OPTIONAL - String | Number
Value of input field
@searchInterval - OPTIONAL - Number
Number in milliseconds before firing onSearch, after user finishes typing
@onChange - REQUIRED - Function
Function that is fired when the value of the input changes
@onSearch - REQUIRED - Function
Function that is fired when the timer triggers after the set searchInterval
@className - OPTIONAL - String
CSS class applied to form wrapper
@placeholder - OPTIONAL - String | Number
Default placeholder value of text input
@resultConfig - REQUIRED - Object
@component: React component that's rendered for each result item
@height: Height for each result item - should be evenly divisible into maxContainerHeight
@onResultSelect: Function that is fired when a list item is selected - should be used to alter state and pass new values to 'value' prop
@maxContainerHeight - OPTIONAL - Number | String
The maximum height of the typeahead results container. The resultConfig.height property should be evenly divisible into maxContainerHeight
###
InputTypeAhead = React.createClass
displayName: 'InputTypeAhead'
propTypes:
resultConfig: React.PropTypes.shape {
component: React.PropTypes.func.isRequired
height: React.PropTypes.number.isRequired
onResultSelect: React.PropTypes.func.isRequired
}
onSearch: React.PropTypes.func.isRequired
onChange: React.PropTypes.func.isRequired
className: React.PropTypes.string
results: React.PropTypes.array.isRequired
minLength: React.PropTypes.number
value: React.PropTypes.oneOfType [
React.PropTypes.number
React.PropTypes.string
]
maxContainerHeight: React.PropTypes.oneOfType [
React.PropTypes.number
React.PropTypes.string
]
placeholder: React.PropTypes.oneOfType [
React.PropTypes.number
React.PropTypes.string
]
searchInterval: React.PropTypes.number
getInitialState: ->
loading: false
selectedResultIndex: 0
value: @props.value or ''
showResults: false
componentWillReceiveProps: (nextProps) ->
showResults = nextProps.results.length > 0 and nextProps.value.length > 0
selectedResultIndex = if showResults and nextProps.value isnt @props.value then 0 else @state.selectedResultIndex
@setState
showResults: showResults
selectedResultIndex: selectedResultIndex
value: nextProps.value
getDefaultProps: ->
className: ''
results: []
minLength: 1
searchInterval: 300
maxContainerHeight: 175
handleClick: (e) ->
e.stopPropagation()
componentWillMount: ->
document.addEventListener('keydown', @handleKeyDown)
window.addEventListener('hashchange', @close)
document.addEventListener('click', @close)
componentWillUnmount: ->
document.removeEventListener('keydown', @handleKeyDown)
window.removeEventListener('hashchange', @close)
document.removeEventListener('click', @close)
close: (e) ->
if @isMounted()
e?.stopPropagation()
@setState
showResults: false
, =>
@props.onClose?()
calculateMaxVisibleResults: ->
{resultConfig, results, maxContainerHeight} = @props
# Figure out how many results can fit in the container
resultsHeight = resultConfig.height * results.length
containerHeight = if resultsHeight > maxContainerHeight then maxContainerHeight else resultsHeight
return containerHeight / resultConfig.height
onResultSelect: (result) ->
@props.resultConfig.onResultSelect(result)
@close()
handleKeyDown: (e) ->
{results} = @props
{selectedResultIndex, showResults, loading} = @state
numResults = @calculateMaxVisibleResults()
#! temporary
loading = false
# Only make these keystrokes work when the menu is open
if loading or not showResults then return
switch e.keyCode
when DOWN_ARROW
e.preventDefault()
e.stopPropagation()
@traverseResults(1)
when UP_ARROW
e.preventDefault()
e.stopPropagation()
@traverseResults(-1)
when PAGE_DOWN
e.preventDefault()
e.stopPropagation()
@traverseResults(numResults)
when PAGE_UP
e.preventDefault()
e.stopPropagation()
@traverseResults(-(numResults))
when ENTER
e.preventDefault()
e.stopPropagation()
result = results[selectedResultIndex]
@onResultSelect(result)
handleChange: ->
@props.onChange()
@executeSearch(@refs.input.getValue())
executeSearch: (simpleTerm) ->
{onSearch, minLength, searchInterval} = @props
{length} = simpleTerm
clearInterval(@searchTimer) if @searchTimer?
if length is 0 then @setState { value: '' }
else if length < minLength then return
else
unless @state.loading then @setState { loading: true }
@searchTimer = setTimeout =>
onSearch?(simpleTerm)
, searchInterval
traverseResults: (change) ->
unless @refs.results? then return
{results, resultConfig} = @props
{selectedResultIndex} = @state
newResult = selectedResultIndex + change
resultsTop = @refs.results.getBoundingClientRect().top
{scrollTop} = @refs.results
# Adjust the change to make sure it will work
if results.length and newResult >= results.length then newResult = results.length - 1
if newResult <= 0 then newResult = 0
itemTop = @refs.resultsList.children[newResult].getBoundingClientRect().top
if itemTop + resultConfig.height > resultsTop + resultConfig.height or itemTop < resultsTop
@refs.results.scrollTop = newResult * resultConfig.height
@setState
selectedResultIndex: newResult
getValue: ->
@refs.input.getValue()
render: ->
{results, className, headerComponent, placeholder, showNoResults, resultConfig} = @props
{selectedResultIndex, loading, value, showResults} = @state
resultItems = []
noResults = not results.length and showNoResults
#! temporary
loading = false
for result, index in results
resultItems.push resultConfig.component {
key: index
selectedResultIndex: selectedResultIndex
index: index
height: resultConfig.height
maxVisibleRows: @calculateMaxVisibleResults()
result: result
onResultSelect: resultConfig.onResultSelect
}
div {
className: "input-type-ahead #{className}"
onClick: @handleClick
}, [
TextInput {
key: 'input'
ref: 'input'
autoComplete: false
value: value
placeholder: placeholder
loading: loading
onChange: @handleChange
className: if resultItems.length and not loading then 'no-radius'
}
headerComponent {
key: 'header'
} if resultItems.length and not loading and headerComponent?
div {
key: 'blah'
className: 'blah'
}
div {
key: 'results'
ref: 'results'
className: 'type-ahead-results'
style:
height: @calculateMaxVisibleResults() * resultConfig.height + 2
}, [
if resultItems.length
ul {
key: 'results-list'
ref: 'resultsList'
}, resultItems
else if noResults
div {
className: 'no-results'
key: 'no-results'
}, 'No Results'
] if not loading and showResults
]
module.exports = InputTypeAhead