Skip to content
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

Fixes #94 - Changes to support Android Chrome #97

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
Open
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
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ React.render(<App />, document.getElementById('app'))
- [`autofocus`](#autofocus-optional)
- [`autoresize`](#autoresize-optional)
- [`delimiters`](#delimiters-optional)
- [`delimiterChars`](#delimitersChars-optional)
- [`delimiterChars`](#delimiterChars-optional)
- [`minQueryLength`](#minquerylength-optional)
- [`maxSuggestionsLength`](#maxsuggestionslength-optional)
- [`classNames`](#classnames-optional)
Expand Down Expand Up @@ -121,11 +121,11 @@ Boolean parameter to control whether the text-input should be automatically resi

#### delimiters (optional)

Array of integers matching keyboard event `keyCode` values. When a corresponding key is pressed, the preceding string is finalised as tag. Default: `[9, 13]` (Tab and return keys).
Array of integers matching keyboard event `keyCode` values. When a corresponding key is pressed, the preceding string is finalised as tag. Best used for non-printable keys, such as the tab and enter/return keys. Default: `[9, 13]` (Tab and return keys).

#### delimiterChars (optional)

Array of characters matching keyboard event `key` values. This is useful when needing to support a specific character irrespective of the keyboard layout. Note, that this list is separate from the one specified by the `delimiters` option, so you'll need to set the value there to `[]`, if you wish to disable those keys. Example usage: `delimiterChars={[',', ' ']}`. Default: `[]`
Array of characters matching characters that can be displayed in an input field. This is useful when needing to support a specific character irrespective of the keyboard layout, such as for internationalisation. Example usage: `delimiterChars={[',', ' ']}`. Default: `[',']`
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice distinction 👍


#### minQueryLength (optional)

Expand Down Expand Up @@ -156,7 +156,7 @@ Override the default class names. Defaults:

#### handleAddition (required)

Function called when the user wants to add a tag. Receives the tag.
Function called when the user wants to add one or more tags. Receives the tag or tags. Value can be a tag or an Array of tags.
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this could be considered a breaking change, it might better to invoke the callback once for each tag to avoid that?

Copy link
Contributor Author

@ajmas ajmas Sep 27, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is, but I couldn't seem to get things to behave otherwise, when I was testing with the example. I can make another branch in my fork to show you the issue?

The issue happens when a paste is made. If I do a single call then then tags stay as should, but if I do multiple calls we lose the intermediate tags. For example, try pasting

Thailand, India, Indonesia,

This is not an issue in normal times.

Here is the other branch to check behaviour with: https://github.com/ajmas/react-tags/tree/no-addtag-array


```js
function (tag) {
Expand Down
1 change: 1 addition & 0 deletions example/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ class App extends React.Component {
return (
<div>
<Tags
delimiterChars={[',', ' ']}
tags={this.state.tags}
suggestions={this.state.suggestions}
handleDelete={this.handleDelete.bind(this)}
Expand Down
131 changes: 122 additions & 9 deletions lib/ReactTags.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,28 +40,131 @@ class ReactTags extends React.Component {
}
}

/* istanbul ignore next: sanity check */
componentWillReceiveProps (newProps) {
this.setState({
classNames: Object.assign({}, CLASS_NAMES, newProps.classNames)
})
}

/**
* Protect against entered characters that could break a RegEx
*/
escapeForRegExp (query) {
return query.replace(/[-\\^$*+?.()|[\]{}]/g, '\\$&')
}

/**
* Handles the value changes to the input field and uses the `delimiterChars`
* property to know on what character to try to create a tag for. Only characters
* valid for display in an `input` field are supported. Other values passed,
* such as 'Tab' and 'Enter' cause adverse effects.
*
* Note, this method is necessary on Android, due to a limitation of the
* `KeyboardEvent.key` having an undefined value, when using soft keyboards.
* in Android's version of Google Chrome. This method also handles the paste
* scenario, without needing to provide a supplemental 'onPaste' handl+er.
*/
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the description!

handleChange (e) {
const query = e.target.value
const { delimiterChars } = this.props

/* istanbul ignore else: sanity check */
if (this.props.handleInputChange) {
this.props.handleInputChange(query)
this.props.handleInputChange(e.target.value)
}

this.setState({ query })
const query = e.target.value

this.setState({ query: query })

if (query && delimiterChars.length > 0) {
const regex = new RegExp('[' + this.escapeForRegExp(delimiterChars.join('')) + ']')

let tagsToAdd = []

// only process if query contains a delimiter character
if (query.match(regex)) {
// split the string based on the delimiterChars as a regex, being sure
// to escape chars, to prevent them being treated as special characters
// also remove any pure white-space entries
const tags = query.split(regex).filter((tag) => {
return tag.trim().length !== 0
})

// handle the case where the last character was not a delimiter, to
// avoid matching text a user was not ready to lookup
let maxTagIdx = tags.length
if (delimiterChars.indexOf(query.charAt(query.length - 1)) < 0) {
--maxTagIdx
}

// deal with case where we don't allow new tags, for now just stop processing
if (!this.props.allowNew) {
let lastTag = tags[maxTagIdx - 1]

const match = this.props.suggestions.findIndex((suggestion) => {
return suggestion.name.toLowerCase() === lastTag.trim().toLowerCase()
})

if (match < 0) {
let toOffset = query.length - 1
// deal with difference between typing and pasting
if (delimiterChars.indexOf(query.charAt(toOffset)) < 0) {
toOffset++
}
this.setState({ query: query.substring(0, toOffset) })
return
}
}

for (let i = 0; i < maxTagIdx; i++) {
// the logic here is similar to handleKeyDown, but subtly different,
// due to the context of the operation
const tag = tags[i].trim()
if (tag.length > 0) {
// look to see if the tag is already known, ignoring case
const matchIdx = this.props.suggestions.findIndex((suggestion) => {
return tag.toLowerCase() === suggestion.name.toLowerCase()
})

// if already known add it, otherwise add it only if we allow new tags
/* istanbul ignore else: sanity check */
if (matchIdx > -1) {
tagsToAdd.push(this.props.suggestions[matchIdx])
} else if (this.props.allowNew) {
tagsToAdd.push({ name: tag.trim() })
}
}
}

// Add all the found tags. We do it one shot, to avoid potential
// state issues.
this.addTag(tagsToAdd)

// if there was remaining undelimited text, add it to the query
if (maxTagIdx < tags.length) {
this.setState({ query: tags[maxTagIdx].trim() })
}
}
}
}

/**
* Handles the keydown event. This method allows handling of special keys,
* such as tab, enter and other meta keys. Use the `delimiter` property
* to define these keys.
*
* Note, While the `KeyboardEvent.keyCode` is considered deprecated, a limitation
* in Android Chrome, related to soft keyboards, prevents us from using the
* `KeyboardEvent.key` attribute. Any other scenario, not handled by this method,
* and related to printable characters, is handled by the `handleChange()` method.
*/
handleKeyDown (e) {
const { query, selectedIndex } = this.state
const { delimiters, delimiterChars } = this.props
const { delimiters } = this.props

// when one of the terminating keys is pressed, add current query to the tags.
if (delimiters.indexOf(e.keyCode) > -1 || delimiterChars.indexOf(e.key) > -1) {
if (delimiters.indexOf(e.keyCode) > -1) {
if (query || selectedIndex > -1) {
e.preventDefault()
}
Expand Down Expand Up @@ -119,12 +222,22 @@ class ReactTags extends React.Component {
this.setState({ focused: true })
}

addTag (tag) {
if (tag.disabled) {
addTag (tags) {
let filteredTags = tags

if (!Array.isArray(filteredTags)) {
filteredTags = [filteredTags]
}

filteredTags = filteredTags.filter((tag) => {
return tag.disabled !== true
})

if (filteredTags.length === 0) {
return
}

this.props.handleAddition(tag)
this.props.handleAddition(filteredTags)

// reset the state
this.setState({
Expand Down Expand Up @@ -194,7 +307,7 @@ ReactTags.defaultProps = {
autofocus: true,
autoresize: true,
delimiters: [KEYS.TAB, KEYS.ENTER],
delimiterChars: [],
delimiterChars: [','],
minQueryLength: 2,
maxSuggestionsLength: 6,
allowNew: false,
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@
"prop-types": "^15.5.0",
"react": "^15.5.0",
"react-dom": "^15.5.0",
"sinon": "^1.17.5",
"sinon": "^4.0.0",
"standard": "^7.1.2",
"webpack": "^1.9.4",
"webpack-dev-server": "^1.8.2"
Expand Down
119 changes: 115 additions & 4 deletions spec/ReactTags.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,12 @@ function type (value) {
})
}

function paste (value) {
$('input').value = value
// React calls onchange following paste
TestUtils.Simulate.change($('input'))
}

function key () {
Array.from(arguments).forEach((value) => {
TestUtils.Simulate.keyDown($('input'), { value, keyCode: keycode(value), key: value })
Expand Down Expand Up @@ -156,7 +162,7 @@ describe('React Tags', () => {
type(query); key('enter')

sinon.assert.calledOnce(props.handleAddition)
sinon.assert.calledWith(props.handleAddition, { name: query })
sinon.assert.calledWith(props.handleAddition, [{ name: query }])
})

it('can add new tags when a delimiter character is entered', () => {
Expand All @@ -166,6 +172,76 @@ describe('React Tags', () => {

sinon.assert.calledThrice(props.handleAddition)
})

it('decriments maxTagIdx, when final character is not a separator', () => {
createInstance({ delimiterChars: [','], allowNew: true })

const input = $('input')

paste('antarctica, spain')

sinon.assert.calledOnce(props.handleAddition)
sinon.assert.calledWith(props.handleAddition, [{ name: 'antarctica' }])

expect(input.value).toEqual('spain')
})

it('adds value on paste, where values are delimiter terminated', () => {
createInstance({ delimiterChars: [','], allowNew: true, handleAddition: props.handleAddition })

paste('Algeria,Guinea Bissau,')

sinon.assert.calledOnce(props.handleAddition)
sinon.assert.calledWith(props.handleAddition, [{ name: 'Algeria' }, { name: 'Guinea Bissau' }])
})

it('does not process final tag on paste, if unrecognised tag', () => {
createInstance({ delimiterChars: [','], allowNew: false, suggestions: fixture })

paste('Thailand,Indonesia')

expect($('input').value).toEqual('Indonesia')
})

it('does not process final tag on paste, if unrecognised tag (white-space test)', () => {
createInstance({ delimiterChars: [','], allowNew: false, suggestions: fixture })

paste('Thailand, Algeria, Indonesia')

expect($('input').value).toEqual('Indonesia')
})

it('does not process final text on paste, if final text is not delimiter terminated', () => {
createInstance({ delimiterChars: [','], allowNew: false, suggestions: fixture })

paste('Thailand,Algeria')

expect($('input').value).toEqual('Algeria')
})

it('checks the trailing delimiter is removed on paste, when tag unrecognised', () => {
createInstance({ delimiterChars: [','], allowNew: false, suggestions: fixture })

paste('United Arab Emirates, Mars,')

expect($('input').value).toEqual('United Arab Emirates, Mars')
})

it('checks the trailing delimiter is removed on typing, when tag unrecognised', () => {
createInstance({ delimiterChars: [','], allowNew: false, suggestions: fixture })

type('Mars,')

expect($('input').value).toEqual('Mars')
})

it('checks last character not removed on paste, if not a delimiter', () => {
createInstance({ delimiterChars: [','], allowNew: false, suggestions: fixture })

paste('xxx, Thailand')

expect($('input').value).toEqual('xxx, Thailand')
})
})

describe('suggestions', () => {
Expand Down Expand Up @@ -291,7 +367,7 @@ describe('React Tags', () => {
type(query); click($('li[role="option"]:nth-child(2)'))

sinon.assert.calledOnce(props.handleAddition)
sinon.assert.calledWith(props.handleAddition, { id: 196, name: 'United Kingdom' })
sinon.assert.calledWith(props.handleAddition, [{ id: 196, name: 'United Kingdom' }])
})

it('triggers addition for the selected suggestion when a delimiter is pressed', () => {
Expand All @@ -302,12 +378,12 @@ describe('React Tags', () => {
type(query); key('down', 'down', 'enter')

sinon.assert.calledOnce(props.handleAddition)
sinon.assert.calledWith(props.handleAddition, { id: 196, name: 'United Kingdom' })
sinon.assert.calledWith(props.handleAddition, [{ id: 196, name: 'United Kingdom' }])
})

it('triggers addition for an unselected but matching suggestion when a delimiter is pressed', () => {
type('united kingdom'); key('enter')
sinon.assert.calledWith(props.handleAddition, { id: 196, name: 'United Kingdom' })
sinon.assert.calledWith(props.handleAddition, [{ id: 196, name: 'United Kingdom' }])
})

it('clears the input when an addition is triggered', () => {
Expand All @@ -318,6 +394,41 @@ describe('React Tags', () => {
expect(input.value).toEqual('')
expect(document.activeElement).toEqual(input)
})

it('does nothing for onchange if there are no delimiterChars', () => {
createInstance({ delimiterChars: [] })

type('united kingdom,')

sinon.assert.notCalled(props.handleAddition)
})

it('checks to see if onchange accepts known tags, during paste', () => {
createInstance({
delimiterChars: [','],
allowNew: false,
handleAddition: props.handleAddition,
suggestions: fixture
})

paste('Thailand,')

sinon.assert.calledOnce(props.handleAddition)
sinon.assert.calledWith(props.handleAddition, [{ id: 184, name: 'Thailand' }])
})

it('checks to see if onchange rejects unknown tags, during paste', () => {
createInstance({
delimiterChars: [','],
allowNew: false,
handleAddition: props.handleAddition,
suggestions: fixture.map((item) => Object.assign({}, item, { disabled: true }))
})

paste('Algeria, abc,')

sinon.assert.notCalled(props.handleAddition)
})
})

describe('tags', () => {
Expand Down