diff --git a/README.md b/README.md index 8fc7eef..9bfad04 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,7 @@ export default App; dataProvider: ( token: string ) => Promise> | Array, + allowWhitespace?: boolean, component: ReactClass<*> |}, } @@ -138,6 +139,7 @@ export default App; - **dataProvider** is called after each keystroke to get data what the suggestion list should display (array or promise resolving array) - **component** is the component for render the item in suggestion list. It has `selected` and `entity` props provided by React Textarea Autocomplete +- **allowWhitespace** (Optional; defaults to false) Set this to true if you want to provide autocomplete for words (tokens) containing whitespace - **output** (Optional for string based item. If the item is an object this method is *required*) This function defines text which will be placed into textarea after the user makes a selection. You can also specify the behavior of caret if you return object `{text: "item", caretPosition: "start"}` the caret will be before the word once the user confirms his selection. Other possible value are "next", "end" and number, which is absolute number in contex of textarea (0 is equal position before the first char). Defaults to "next" which is space after the injected word. diff --git a/__tests__/index.spec.js b/__tests__/index.spec.js index 18065ab..89ac0c8 100644 --- a/__tests__/index.spec.js +++ b/__tests__/index.spec.js @@ -4,7 +4,9 @@ import ReactTextareaAutocomplete from '../src'; import Item from '../src/Item'; // eslint-disable-next-line -const SmileItemComponent = ({ entity: { label, text } }) =>
{label}
; +const SmileItemComponent = ({ entity: { label, text } }) => ( +
{label}
+); const Loading = () =>
Loading...
; @@ -118,9 +120,7 @@ describe('object-based items', () => { }); it('text in textarea should be changed', () => { - expect(rta.find('textarea').node.value).toBe( - '___happy_face___ some test :a' - ); + expect(rta.find('textarea').node.value).toBe('some test ___happy_face___ '); }); }); @@ -201,7 +201,7 @@ describe('string-based items w/o output fn', () => { }); it('text in textarea should be changed', () => { - expect(rta.find('textarea').node.value).toBe(':happy_face: some test :a'); + expect(rta.find('textarea').node.value).toBe('some test :happy_face: '); }); }); @@ -282,7 +282,7 @@ describe('string-based items with output fn', () => { }); it('text in textarea should be changed', () => { - expect(rta.find('textarea').node.value).toBe('__happy_face__ some test :a'); + expect(rta.find('textarea').node.value).toBe('some test __happy_face__ '); }); }); @@ -440,8 +440,6 @@ describe('object-based items with keys', () => { }); it('text in textarea should be changed', () => { - expect(rta.find('textarea').node.value).toBe( - '___happy_face___ some test :a' - ); + expect(rta.find('textarea').node.value).toBe('some test ___happy_face___ '); }); }); diff --git a/cypress/integration/textarea.js b/cypress/integration/textarea.js index cf63d13..e5790fc 100644 --- a/cypress/integration/textarea.js +++ b/cypress/integration/textarea.js @@ -1,3 +1,20 @@ +/** + Helper function for a repeating of commands + + e.g : cy + .get('.rta__textarea') + .type(`${repeat('{backspace}', 13)} again {downarrow}{enter}`); + */ +function repeat(string, times = 1) { + let result = ''; + let round = times; + while (round--) { + result += string; + } + + return result; +} + describe('React Textarea Autocomplete', () => { it('server is reachable', () => { cy.visit('http://localhost:8080'); @@ -29,7 +46,7 @@ describe('React Textarea Autocomplete', () => { it('special character like [, ( should be also possible to use as trigger char', () => { cy .get('.rta__textarea') - .type('This is test [{downarrow}{enter}') + .type('This is test [{enter}') .should('have.value', 'This is test @'); }); @@ -186,5 +203,17 @@ describe('React Textarea Autocomplete', () => { cy.get('.rta__autocomplete').should('not.be.visible'); }); + + it('should allows tokens with eventual whitespace', () => { + cy.get('.rta__textarea').type('This is test [another charact'); + cy.get('[data-test="actualToken"]').contains('another charact'); + cy.get('.rta__textarea').type('{esc} and', { force: true }); + cy + .get('.rta__textarea') + .type(`${repeat('{backspace}', 13)} again {downarrow}{enter}`, { + force: true, + }); + cy.get('.rta__textarea').should('have.value', 'This is test /'); + }); }); }); diff --git a/example/App.jsx b/example/App.jsx index 4360786..87da800 100644 --- a/example/App.jsx +++ b/example/App.jsx @@ -30,6 +30,7 @@ class App extends React.Component { movePopupAsYouType: false, text: '', optionsCaret: 'start', + actualTokenInProvider: '', }; _handleOptionsCaretEnd = () => { @@ -102,6 +103,7 @@ class App extends React.Component { caretPosition, clickoutsideOption, movePopupAsYouType, + actualTokenInProvider, text, } = this.state; @@ -170,6 +172,10 @@ class App extends React.Component { +
+ Actual token in "[" provider:{' '} + {actualTokenInProvider} +
[{ name: 'alt', char: '@' }], + dataProvider: token => { + /** + Let's pass token to state to easily test it in Cypress + We going to test that we get also whitespace because this trigger has set "allowWhitespace" + */ + this.setState({ actualTokenInProvider: token }); + return [ + { name: 'alt', char: '@' }, + { name: 'another character', char: '/' }, + ]; + }, component: Item, + allowWhitespace: true, output: { start: this._outputCaretStart, end: this._outputCaretEnd, diff --git a/src/Textarea.jsx b/src/Textarea.jsx index 6aec44d..2206d86 100644 --- a/src/Textarea.jsx +++ b/src/Textarea.jsx @@ -25,6 +25,7 @@ const errorMessage = (message: string) => message } Check the documentation or create issue if you think it's bug. https://github.com/webscopeio/react-textarea-autocomplete/issues` ); + class ReactTextareaAutocomplete extends React.Component< TextareaProps, TextareaState @@ -99,7 +100,7 @@ class ReactTextareaAutocomplete extends React.Component< _onSelect = (newToken: textToReplaceType) => { const { selectionEnd, currentTrigger, value: textareaValue } = this.state; - const { onChange } = this.props; + const { onChange, trigger } = this.props; if (!currentTrigger) return; @@ -125,24 +126,17 @@ class ReactTextareaAutocomplete extends React.Component< } }; - let offsetToEndOfToken = 0; - while ( - textareaValue[selectionEnd + offsetToEndOfToken] && - /\S/.test(textareaValue[selectionEnd + offsetToEndOfToken]) - ) { - offsetToEndOfToken += 1; - } - - const textToModify = textareaValue.slice( - 0, - selectionEnd + offsetToEndOfToken - ); + const textToModify = textareaValue.slice(0, selectionEnd); const startOfTokenPosition = textToModify.search( /** - * It's important to enscape the currentTrigger char for chars like [, (,... + * It's important to escape the currentTrigger char for chars like [, (,... */ - new RegExp(`\\${currentTrigger}\\S*$`) + new RegExp( + `\\${currentTrigger}${ + trigger[currentTrigger].allowWhitespace ? '.' : '\\S' + }*$` + ) ); // we add space after emoji is selected if a caret position is next @@ -269,6 +263,9 @@ class ReactTextareaAutocomplete extends React.Component< throw new Error('Component should be defined!'); } + // throw away if we resolved old trigger + if (currentTrigger !== this.state.currentTrigger) return; + this.setState({ dataLoading: false, data, @@ -381,18 +378,43 @@ class ReactTextareaAutocomplete extends React.Component< value, }); - const tokenMatch = this.tokenRegExp.exec(value.slice(0, selectionEnd)); - const lastToken = tokenMatch && tokenMatch[0]; + let tokenMatch = this.tokenRegExp.exec(value.slice(0, selectionEnd)); + let lastToken = tokenMatch && tokenMatch[0]; /* if we lost the trigger token or there is no following character we want to close the autocomplete */ - if (!lastToken || lastToken.length <= minChar) { + if ( + (!lastToken || lastToken.length <= minChar) && + // check if our current trigger disallows whitespace + ((this.state.currentTrigger && + !trigger[this.state.currentTrigger].allowWhitespace) || + !this.state.currentTrigger) + ) { this._closeAutocomplete(); return; } + /** + If our current trigger allows whitespace + get the correct token for DataProvider, so we need to construct new RegExp + */ + if ( + this.state.currentTrigger && + trigger[this.state.currentTrigger].allowWhitespace + ) { + tokenMatch = new RegExp( + `\\${this.state.currentTrigger}[^${this.state.currentTrigger}]*$` + ).exec(value.slice(0, selectionEnd)); + lastToken = tokenMatch && tokenMatch[0]; + + if (!lastToken) { + this._closeAutocomplete(); + return; + } + } + const triggerChars = Object.keys(trigger); const currentTrigger = diff --git a/src/types.js b/src/types.js index 95ccc19..a025a7d 100644 --- a/src/types.js +++ b/src/types.js @@ -51,6 +51,7 @@ export type ListState = { export type settingType = {| component: React$StatelessFunctionalComponent<*>, dataProvider: dataProviderType, + allowWhitespace?: boolean, output?: (Object | string, ?string) => textToReplaceType | string, |};