diff --git a/README.md b/README.md index 2860b92..1c5b839 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ React.render(, 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) @@ -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: `[',']` #### minQueryLength (optional) @@ -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. ```js function (tag) { diff --git a/example/main.js b/example/main.js index 1170719..50a98ab 100644 --- a/example/main.js +++ b/example/main.js @@ -33,6 +33,7 @@ class App extends React.Component { return (
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() } @@ -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({ @@ -194,7 +307,7 @@ ReactTags.defaultProps = { autofocus: true, autoresize: true, delimiters: [KEYS.TAB, KEYS.ENTER], - delimiterChars: [], + delimiterChars: [','], minQueryLength: 2, maxSuggestionsLength: 6, allowNew: false, diff --git a/package.json b/package.json index 13fda52..bf317b7 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/spec/ReactTags.spec.js b/spec/ReactTags.spec.js index 947443e..f50074f 100644 --- a/spec/ReactTags.spec.js +++ b/spec/ReactTags.spec.js @@ -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 }) @@ -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', () => { @@ -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', () => { @@ -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', () => { @@ -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', () => { @@ -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', () => {