You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

328 lines
9.0 KiB

  1. import React from 'react';
  2. import PropTypes from 'prop-types';
  3. import { injectIntl, defineMessages } from 'react-intl';
  4. import TextIconButton from './text_icon_button';
  5. import Overlay from 'react-overlays/Overlay';
  6. import { supportsPassiveEvents } from 'detect-passive-events';
  7. import classNames from 'classnames';
  8. import { languages as preloadedLanguages } from 'mastodon/initial_state';
  9. import { loupeIcon, deleteIcon } from 'mastodon/utils/icons';
  10. import fuzzysort from 'fuzzysort';
  11. const messages = defineMessages({
  12. changeLanguage: { id: 'compose.language.change', defaultMessage: 'Change language' },
  13. search: { id: 'compose.language.search', defaultMessage: 'Search languages...' },
  14. clear: { id: 'emoji_button.clear', defaultMessage: 'Clear' },
  15. });
  16. const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
  17. class LanguageDropdownMenu extends React.PureComponent {
  18. static propTypes = {
  19. value: PropTypes.string.isRequired,
  20. frequentlyUsedLanguages: PropTypes.arrayOf(PropTypes.string).isRequired,
  21. onClose: PropTypes.func.isRequired,
  22. onChange: PropTypes.func.isRequired,
  23. languages: PropTypes.arrayOf(PropTypes.arrayOf(PropTypes.string)),
  24. intl: PropTypes.object,
  25. };
  26. static defaultProps = {
  27. languages: preloadedLanguages,
  28. };
  29. state = {
  30. searchValue: '',
  31. };
  32. handleDocumentClick = e => {
  33. if (this.node && !this.node.contains(e.target)) {
  34. this.props.onClose();
  35. }
  36. };
  37. componentDidMount () {
  38. document.addEventListener('click', this.handleDocumentClick, false);
  39. document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
  40. // Because of https://github.com/react-bootstrap/react-bootstrap/issues/2614 we need
  41. // to wait for a frame before focusing
  42. requestAnimationFrame(() => {
  43. if (this.node) {
  44. const element = this.node.querySelector('input[type="search"]');
  45. if (element) element.focus();
  46. }
  47. });
  48. }
  49. componentWillUnmount () {
  50. document.removeEventListener('click', this.handleDocumentClick, false);
  51. document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
  52. }
  53. setRef = c => {
  54. this.node = c;
  55. };
  56. setListRef = c => {
  57. this.listNode = c;
  58. };
  59. handleSearchChange = ({ target }) => {
  60. this.setState({ searchValue: target.value });
  61. };
  62. search () {
  63. const { languages, value, frequentlyUsedLanguages } = this.props;
  64. const { searchValue } = this.state;
  65. if (searchValue === '') {
  66. return [...languages].sort((a, b) => {
  67. // Push current selection to the top of the list
  68. if (a[0] === value) {
  69. return -1;
  70. } else if (b[0] === value) {
  71. return 1;
  72. } else {
  73. // Sort according to frequently used languages
  74. const indexOfA = frequentlyUsedLanguages.indexOf(a[0]);
  75. const indexOfB = frequentlyUsedLanguages.indexOf(b[0]);
  76. return ((indexOfA > -1 ? indexOfA : Infinity) - (indexOfB > -1 ? indexOfB : Infinity));
  77. }
  78. });
  79. }
  80. return fuzzysort.go(searchValue, languages, {
  81. keys: ['0', '1', '2'],
  82. limit: 5,
  83. threshold: -10000,
  84. }).map(result => result.obj);
  85. }
  86. frequentlyUsed () {
  87. const { languages, value } = this.props;
  88. const current = languages.find(lang => lang[0] === value);
  89. const results = [];
  90. if (current) {
  91. results.push(current);
  92. }
  93. return results;
  94. }
  95. handleClick = e => {
  96. const value = e.currentTarget.getAttribute('data-index');
  97. e.preventDefault();
  98. this.props.onClose();
  99. this.props.onChange(value);
  100. };
  101. handleKeyDown = e => {
  102. const { onClose } = this.props;
  103. const index = Array.from(this.listNode.childNodes).findIndex(node => node === e.currentTarget);
  104. let element = null;
  105. switch(e.key) {
  106. case 'Escape':
  107. onClose();
  108. break;
  109. case 'Enter':
  110. this.handleClick(e);
  111. break;
  112. case 'ArrowDown':
  113. element = this.listNode.childNodes[index + 1] || this.listNode.firstChild;
  114. break;
  115. case 'ArrowUp':
  116. element = this.listNode.childNodes[index - 1] || this.listNode.lastChild;
  117. break;
  118. case 'Tab':
  119. if (e.shiftKey) {
  120. element = this.listNode.childNodes[index - 1] || this.listNode.lastChild;
  121. } else {
  122. element = this.listNode.childNodes[index + 1] || this.listNode.firstChild;
  123. }
  124. break;
  125. case 'Home':
  126. element = this.listNode.firstChild;
  127. break;
  128. case 'End':
  129. element = this.listNode.lastChild;
  130. break;
  131. }
  132. if (element) {
  133. element.focus();
  134. e.preventDefault();
  135. e.stopPropagation();
  136. }
  137. };
  138. handleSearchKeyDown = e => {
  139. const { onChange, onClose } = this.props;
  140. const { searchValue } = this.state;
  141. let element = null;
  142. switch(e.key) {
  143. case 'Tab':
  144. case 'ArrowDown':
  145. element = this.listNode.firstChild;
  146. if (element) {
  147. element.focus();
  148. e.preventDefault();
  149. e.stopPropagation();
  150. }
  151. break;
  152. case 'Enter':
  153. element = this.listNode.firstChild;
  154. if (element) {
  155. onChange(element.getAttribute('data-index'));
  156. onClose();
  157. }
  158. break;
  159. case 'Escape':
  160. if (searchValue !== '') {
  161. e.preventDefault();
  162. this.handleClear();
  163. }
  164. break;
  165. }
  166. };
  167. handleClear = () => {
  168. this.setState({ searchValue: '' });
  169. };
  170. renderItem = lang => {
  171. const { value } = this.props;
  172. return (
  173. <div key={lang[0]} role='option' tabIndex='0' data-index={lang[0]} className={classNames('language-dropdown__dropdown__results__item', { active: lang[0] === value })} aria-selected={lang[0] === value} onClick={this.handleClick} onKeyDown={this.handleKeyDown}>
  174. <span className='language-dropdown__dropdown__results__item__native-name' lang={lang[0]}>{lang[2]}</span> <span className='language-dropdown__dropdown__results__item__common-name'>({lang[1]})</span>
  175. </div>
  176. );
  177. };
  178. render () {
  179. const { intl } = this.props;
  180. const { searchValue } = this.state;
  181. const isSearching = searchValue !== '';
  182. const results = this.search();
  183. return (
  184. <div ref={this.setRef}>
  185. <div className='emoji-mart-search'>
  186. <input type='search' value={searchValue} onChange={this.handleSearchChange} onKeyDown={this.handleSearchKeyDown} placeholder={intl.formatMessage(messages.search)} />
  187. <button type='button' className='emoji-mart-search-icon' disabled={!isSearching} aria-label={intl.formatMessage(messages.clear)} onClick={this.handleClear}>{!isSearching ? loupeIcon : deleteIcon}</button>
  188. </div>
  189. <div className='language-dropdown__dropdown__results emoji-mart-scroll' role='listbox' ref={this.setListRef}>
  190. {results.map(this.renderItem)}
  191. </div>
  192. </div>
  193. );
  194. }
  195. }
  196. class LanguageDropdown extends React.PureComponent {
  197. static propTypes = {
  198. value: PropTypes.string,
  199. frequentlyUsedLanguages: PropTypes.arrayOf(PropTypes.string),
  200. intl: PropTypes.object.isRequired,
  201. onChange: PropTypes.func,
  202. onClose: PropTypes.func,
  203. };
  204. state = {
  205. open: false,
  206. placement: 'bottom',
  207. };
  208. handleToggle = () => {
  209. if (this.state.open && this.activeElement) {
  210. this.activeElement.focus({ preventScroll: true });
  211. }
  212. this.setState({ open: !this.state.open });
  213. };
  214. handleClose = () => {
  215. const { value, onClose } = this.props;
  216. if (this.state.open && this.activeElement) {
  217. this.activeElement.focus({ preventScroll: true });
  218. }
  219. this.setState({ open: false });
  220. onClose(value);
  221. };
  222. handleChange = value => {
  223. const { onChange } = this.props;
  224. onChange(value);
  225. };
  226. setTargetRef = c => {
  227. this.target = c;
  228. };
  229. findTarget = () => {
  230. return this.target;
  231. };
  232. handleOverlayEnter = (state) => {
  233. this.setState({ placement: state.placement });
  234. };
  235. render () {
  236. const { value, intl, frequentlyUsedLanguages } = this.props;
  237. const { open, placement } = this.state;
  238. return (
  239. <div className={classNames('privacy-dropdown', placement, { active: open })}>
  240. <div className='privacy-dropdown__value' ref={this.setTargetRef} >
  241. <TextIconButton
  242. className='privacy-dropdown__value-icon'
  243. label={value && value.toUpperCase()}
  244. title={intl.formatMessage(messages.changeLanguage)}
  245. active={open}
  246. onClick={this.handleToggle}
  247. />
  248. </div>
  249. <Overlay show={open} placement={'bottom'} flip target={this.findTarget} popperConfig={{ strategy: 'fixed', onFirstUpdate: this.handleOverlayEnter }}>
  250. {({ props, placement }) => (
  251. <div {...props}>
  252. <div className={`dropdown-animation language-dropdown__dropdown ${placement}`} >
  253. <LanguageDropdownMenu
  254. value={value}
  255. frequentlyUsedLanguages={frequentlyUsedLanguages}
  256. onClose={this.handleClose}
  257. onChange={this.handleChange}
  258. intl={intl}
  259. />
  260. </div>
  261. </div>
  262. )}
  263. </Overlay>
  264. </div>
  265. );
  266. }
  267. }
  268. export default injectIntl(LanguageDropdown);