Skip to content

Refactor suggestions #115

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Jan 22, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions lib/Input.js
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ class Input extends React.Component {
}

render () {
const { query, placeholder, expanded, listboxId, selected } = this.props
const { query, placeholder, expanded, listboxId, index } = this.props

return (
<div className={this.props.classNames.searchInput}>
Expand All @@ -75,7 +75,7 @@ class Input extends React.Component {
aria-autocomplete='list'
aria-label={placeholder}
aria-owns={listboxId}
aria-activedescendant={selected > -1 ? `${listboxId}-${selected}` : null}
aria-activedescendant={index > -1 ? `${listboxId}-${index}` : null}
aria-expanded={expanded}
style={{ width: this.state.inputWidth }} />
<div ref={(c) => { this.sizer = c }} style={SIZER_STYLES}>{query || placeholder}</div>
Expand Down
112 changes: 68 additions & 44 deletions lib/ReactTags.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import PropTypes from 'prop-types'
import Tag from './Tag'
import Input from './Input'
import Suggestions from './Suggestions'
import { matchExact, matchPartial } from './concerns/matchers'

const KEYS = {
ENTER: 'Enter',
Expand All @@ -25,15 +26,64 @@ const CLASS_NAMES = {
suggestionDisabled: 'is-disabled'
}

function pressDelimiterKey (e) {
if (this.state.query || this.state.index > -1) {
e.preventDefault()
}

if (this.state.query.length >= this.props.minQueryLength) {
// Check if the user typed in an existing suggestion.
const match = this.state.options.findIndex((option) => {
return matchExact(this.state.query).test(option.name)
})

const index = this.state.index === -1 ? match : this.state.index

if (index > -1) {
this.addTag(this.state.options[index])
} else if (this.props.allowNew) {
this.addTag({ name: this.state.query })
}
}
}

function pressUpKey (e) {
e.preventDefault()

// if first item, cycle to the bottom
const size = this.state.options.length - 1
this.setState({ index: this.state.index <= 0 ? size : this.state.index - 1 })
}

function pressDownKey (e) {
e.preventDefault()

// if last item, cycle to top
const size = this.state.options.length - 1
this.setState({ index: this.state.index >= size ? 0 : this.state.index + 1 })
}

function pressBackspaceKey () {
// when backspace key is pressed and query is blank, delete the last tag
if (!this.state.query.length) {
this.deleteTag(this.props.tags.length - 1)
}
}

function filterSuggestions (query, suggestions) {
const regexp = matchPartial(query)
return suggestions.filter((item) => regexp.test(item.name))
}

class ReactTags extends React.Component {
constructor (props) {
super(props)

this.state = {
query: '',
focused: false,
expanded: false,
selected: -1,
options: [],
index: -1,
classNames: Object.assign({}, CLASS_NAMES, this.props.classNames)
}
}
Expand All @@ -51,55 +101,31 @@ class ReactTags extends React.Component {
this.props.onInput(query)
}

this.setState({ query })
if (query !== this.state.query) {
const filtered = filterSuggestions(query, this.props.suggestions)
const options = filtered.slice(0, this.props.maxSuggestionsLength)

this.setState({ query, options })
}
}

onKeyDown (e) {
const { query, selected } = this.state
const { delimiters } = this.props

// when one of the terminating keys is pressed, add current query to the tags.
if (delimiters.indexOf(e.key) > -1) {
if (query || selected > -1) {
e.preventDefault()
}

if (query.length >= this.props.minQueryLength) {
// Check if the user typed in an existing suggestion.
const match = this.suggestions.state.options.findIndex((suggestion) => (
suggestion.name.search(new RegExp(`^${query}$`, 'i')) === 0
))

const index = selected === -1 ? match : selected

if (index > -1) {
this.addTag(this.suggestions.state.options[index])
} else if (this.props.allowNew) {
this.addTag({ name: query })
}
}
// when one of the terminating keys is pressed, add current query to the tags
if (this.props.delimiters.indexOf(e.key) > -1) {
pressDelimiterKey.call(this, e)
}

// when backspace key is pressed and query is blank, delete the last tag
if (e.key === KEYS.BACKSPACE && query.length === 0 && this.props.allowBackspace) {
this.deleteTag(this.props.tags.length - 1)
if (e.key === KEYS.BACKSPACE && this.props.allowBackspace) {
pressBackspaceKey.call(this, e)
}

if (e.key === KEYS.UP_ARROW) {
e.preventDefault()

// if last item, cycle to the bottom
if (selected <= 0) {
this.setState({ selected: this.suggestions.state.options.length - 1 })
} else {
this.setState({ selected: selected - 1 })
}
pressUpKey.call(this, e)
}

if (e.key === KEYS.DOWN_ARROW) {
e.preventDefault()

this.setState({ selected: (selected + 1) % this.suggestions.state.options.length })
pressDownKey.call(this, e)
}
}

Expand All @@ -110,7 +136,7 @@ class ReactTags extends React.Component {
}

onBlur () {
this.setState({ focused: false, selected: -1 })
this.setState({ focused: false, index: -1 })

if (this.props.onBlur) {
this.props.onBlur()
Expand All @@ -135,7 +161,7 @@ class ReactTags extends React.Component {
// reset the state
this.setState({
query: '',
selected: -1
index: -1
})
}

Expand Down Expand Up @@ -183,9 +209,7 @@ class ReactTags extends React.Component {
ref={(c) => { this.suggestions = c }}
listboxId={listboxId}
expanded={expanded}
suggestions={this.props.suggestions}
addTag={this.addTag.bind(this)}
maxSuggestionsLength={this.props.maxSuggestionsLength} />
addTag={this.addTag.bind(this)} />
</div>
</div>
)
Expand Down
41 changes: 8 additions & 33 deletions lib/Suggestions.js
Original file line number Diff line number Diff line change
@@ -1,53 +1,28 @@
import React from 'react'
import { matchAny } from './concerns/matchers'

function escapeForRegExp (query) {
return query.replace(/[-\\^$*+?.()|[\]{}]/g, '\\$&')
}

function markIt (input, query) {
const regex = RegExp(escapeForRegExp(query), 'gi')

return {
__html: input.replace(regex, '<mark>$&</mark>')
}
}

function filterSuggestions (query, suggestions, length) {
const regex = new RegExp(`(?:^|\\s)${escapeForRegExp(query)}`, 'i')
return suggestions.filter((item) => regex.test(item.name)).slice(0, length)
function markIt (name, query) {
const regexp = matchAny(query)
return name.replace(regexp, '<mark>$&</mark>')
}

class Suggestions extends React.Component {
constructor (props) {
super(props)

this.state = {
options: filterSuggestions(this.props.query, this.props.suggestions, this.props.maxSuggestionsLength)
}
}

componentWillReceiveProps (newProps) {
this.setState({
options: filterSuggestions(newProps.query, newProps.suggestions, newProps.maxSuggestionsLength)
})
}

onMouseDown (item, e) {
// focus is shifted on mouse down but calling preventDefault prevents this
e.preventDefault()
this.props.addTag(item)
}

render () {
if (!this.props.expanded || !this.state.options.length) {
if (!this.props.expanded || !this.props.options.length) {
return null
}

const options = this.state.options.map((item, i) => {
const options = this.props.options.map((item, i) => {
const key = `${this.props.listboxId}-${i}`
const classNames = []

if (this.props.selected === i) {
if (this.props.index === i) {
classNames.push(this.props.classNames.suggestionActive)
}

Expand All @@ -63,7 +38,7 @@ class Suggestions extends React.Component {
className={classNames.join(' ')}
aria-disabled={item.disabled === true}
onMouseDown={this.onMouseDown.bind(this, item)}>
<span dangerouslySetInnerHTML={markIt(item.name, this.props.query)} />
<span dangerouslySetInnerHTML={{ __html: markIt(item.name, this.props.query) }} />
</li>
)
})
Expand Down
15 changes: 15 additions & 0 deletions lib/concerns/matchers.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
function escapeForRegExp (string) {
return string.replace(/[-\\^$*+?.()|[\]{}]/g, '\\$&')
}

export function matchAny (string) {
return new RegExp(escapeForRegExp(string), 'gi')
}

export function matchPartial (string) {
return new RegExp(`(?:^|\\s)${escapeForRegExp(string)}`, 'i')
}

export function matchExact (string) {
return new RegExp(`^${escapeForRegExp(string)}$`, 'i')
}