autosuggest_input.jsx 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230
  1. import PropTypes from 'prop-types';
  2. import classNames from 'classnames';
  3. import ImmutablePropTypes from 'react-immutable-proptypes';
  4. import ImmutablePureComponent from 'react-immutable-pure-component';
  5. import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container';
  6. import AutosuggestEmoji from './autosuggest_emoji';
  7. import { AutosuggestHashtag } from './autosuggest_hashtag';
  8. const textAtCursorMatchesToken = (str, caretPosition, searchTokens) => {
  9. let word;
  10. let left = str.slice(0, caretPosition).search(/\S+$/);
  11. let right = str.slice(caretPosition).search(/\s/);
  12. if (right < 0) {
  13. word = str.slice(left);
  14. } else {
  15. word = str.slice(left, right + caretPosition);
  16. }
  17. if (!word || word.trim().length < 3 || searchTokens.indexOf(word[0]) === -1) {
  18. return [null, null];
  19. }
  20. word = word.trim().toLowerCase();
  21. if (word.length > 0) {
  22. return [left + 1, word];
  23. } else {
  24. return [null, null];
  25. }
  26. };
  27. export default class AutosuggestInput extends ImmutablePureComponent {
  28. static propTypes = {
  29. value: PropTypes.string,
  30. suggestions: ImmutablePropTypes.list,
  31. disabled: PropTypes.bool,
  32. placeholder: PropTypes.string,
  33. onSuggestionSelected: PropTypes.func.isRequired,
  34. onSuggestionsClearRequested: PropTypes.func.isRequired,
  35. onSuggestionsFetchRequested: PropTypes.func.isRequired,
  36. onChange: PropTypes.func.isRequired,
  37. onKeyUp: PropTypes.func,
  38. onKeyDown: PropTypes.func,
  39. autoFocus: PropTypes.bool,
  40. className: PropTypes.string,
  41. id: PropTypes.string,
  42. searchTokens: PropTypes.arrayOf(PropTypes.string),
  43. maxLength: PropTypes.number,
  44. lang: PropTypes.string,
  45. spellCheck: PropTypes.bool,
  46. };
  47. static defaultProps = {
  48. autoFocus: true,
  49. searchTokens: ['@', ':', '#'],
  50. };
  51. state = {
  52. suggestionsHidden: true,
  53. focused: false,
  54. selectedSuggestion: 0,
  55. lastToken: null,
  56. tokenStart: 0,
  57. };
  58. onChange = (e) => {
  59. const [ tokenStart, token ] = textAtCursorMatchesToken(e.target.value, e.target.selectionStart, this.props.searchTokens);
  60. if (token !== null && this.state.lastToken !== token) {
  61. this.setState({ lastToken: token, selectedSuggestion: 0, tokenStart });
  62. this.props.onSuggestionsFetchRequested(token);
  63. } else if (token === null) {
  64. this.setState({ lastToken: null });
  65. this.props.onSuggestionsClearRequested();
  66. }
  67. this.props.onChange(e);
  68. };
  69. onKeyDown = (e) => {
  70. const { suggestions, disabled } = this.props;
  71. const { selectedSuggestion, suggestionsHidden } = this.state;
  72. if (disabled) {
  73. e.preventDefault();
  74. return;
  75. }
  76. if (e.which === 229 || e.isComposing) {
  77. // Ignore key events during text composition
  78. // e.key may be a name of the physical key even in this case (e.x. Safari / Chrome on Mac)
  79. return;
  80. }
  81. switch(e.key) {
  82. case 'Escape':
  83. if (suggestions.size === 0 || suggestionsHidden) {
  84. document.querySelector('.ui').parentElement.focus();
  85. } else {
  86. e.preventDefault();
  87. this.setState({ suggestionsHidden: true });
  88. }
  89. break;
  90. case 'ArrowDown':
  91. if (suggestions.size > 0 && !suggestionsHidden) {
  92. e.preventDefault();
  93. this.setState({ selectedSuggestion: Math.min(selectedSuggestion + 1, suggestions.size - 1) });
  94. }
  95. break;
  96. case 'ArrowUp':
  97. if (suggestions.size > 0 && !suggestionsHidden) {
  98. e.preventDefault();
  99. this.setState({ selectedSuggestion: Math.max(selectedSuggestion - 1, 0) });
  100. }
  101. break;
  102. case 'Enter':
  103. case 'Tab':
  104. // Select suggestion
  105. if (this.state.lastToken !== null && suggestions.size > 0 && !suggestionsHidden) {
  106. e.preventDefault();
  107. e.stopPropagation();
  108. this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestions.get(selectedSuggestion));
  109. }
  110. break;
  111. }
  112. if (e.defaultPrevented || !this.props.onKeyDown) {
  113. return;
  114. }
  115. this.props.onKeyDown(e);
  116. };
  117. onBlur = () => {
  118. this.setState({ suggestionsHidden: true, focused: false });
  119. };
  120. onFocus = () => {
  121. this.setState({ focused: true });
  122. };
  123. onSuggestionClick = (e) => {
  124. const suggestion = this.props.suggestions.get(e.currentTarget.getAttribute('data-index'));
  125. e.preventDefault();
  126. this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestion);
  127. this.input.focus();
  128. };
  129. UNSAFE_componentWillReceiveProps (nextProps) {
  130. if (nextProps.suggestions !== this.props.suggestions && nextProps.suggestions.size > 0 && this.state.suggestionsHidden && this.state.focused) {
  131. this.setState({ suggestionsHidden: false });
  132. }
  133. }
  134. setInput = (c) => {
  135. this.input = c;
  136. };
  137. renderSuggestion = (suggestion, i) => {
  138. const { selectedSuggestion } = this.state;
  139. let inner, key;
  140. if (suggestion.type === 'emoji') {
  141. inner = <AutosuggestEmoji emoji={suggestion} />;
  142. key = suggestion.id;
  143. } else if (suggestion.type ==='hashtag') {
  144. inner = <AutosuggestHashtag tag={suggestion} />;
  145. key = suggestion.name;
  146. } else if (suggestion.type === 'account') {
  147. inner = <AutosuggestAccountContainer id={suggestion.id} />;
  148. key = suggestion.id;
  149. }
  150. return (
  151. <div role='button' tabIndex={0} key={key} data-index={i} className={classNames('autosuggest-textarea__suggestions__item', { selected: i === selectedSuggestion })} onMouseDown={this.onSuggestionClick}>
  152. {inner}
  153. </div>
  154. );
  155. };
  156. render () {
  157. const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus, className, id, maxLength, lang, spellCheck } = this.props;
  158. const { suggestionsHidden } = this.state;
  159. return (
  160. <div className='autosuggest-input'>
  161. <label>
  162. <span style={{ display: 'none' }}>{placeholder}</span>
  163. <input
  164. type='text'
  165. ref={this.setInput}
  166. disabled={disabled}
  167. placeholder={placeholder}
  168. autoFocus={autoFocus}
  169. value={value}
  170. onChange={this.onChange}
  171. onKeyDown={this.onKeyDown}
  172. onKeyUp={onKeyUp}
  173. onFocus={this.onFocus}
  174. onBlur={this.onBlur}
  175. dir='auto'
  176. aria-autocomplete='list'
  177. id={id}
  178. className={className}
  179. maxLength={maxLength}
  180. lang={lang}
  181. spellCheck={spellCheck}
  182. />
  183. </label>
  184. <div className={`autosuggest-textarea__suggestions ${suggestionsHidden || suggestions.isEmpty() ? '' : 'autosuggest-textarea__suggestions--visible'}`}>
  185. {suggestions.map(this.renderSuggestion)}
  186. </div>
  187. </div>
  188. );
  189. }
  190. }