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.

PollCreate.tsx 10KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286
  1. /* eslint-disable lines-around-comment */
  2. import React, { useCallback, useEffect, useRef, useState } from 'react';
  3. import { makeStyles } from 'tss-react/mui';
  4. import Icon from '../../../base/icons/components/Icon';
  5. import { IconBurger } from '../../../base/icons/svg';
  6. // @ts-ignore
  7. import { Tooltip } from '../../../base/tooltip';
  8. import Button from '../../../base/ui/components/web/Button';
  9. import { BUTTON_TYPES } from '../../../base/ui/constants.web';
  10. import { ANSWERS_LIMIT, CHAR_LIMIT } from '../../constants';
  11. // @ts-ignore
  12. import AbstractPollCreate from '../AbstractPollCreate';
  13. // @ts-ignore
  14. import type { AbstractProps } from '../AbstractPollCreate';
  15. const useStyles = makeStyles()(theme => {
  16. return {
  17. buttonMargin: {
  18. marginRight: theme.spacing(2)
  19. }
  20. };
  21. });
  22. const PollCreate = ({
  23. addAnswer,
  24. answers,
  25. isSubmitDisabled,
  26. moveAnswer,
  27. onSubmit,
  28. question,
  29. removeAnswer,
  30. setAnswer,
  31. setCreateMode,
  32. setQuestion,
  33. t
  34. }: AbstractProps) => {
  35. const { classes: styles } = useStyles();
  36. /*
  37. * This ref stores the Array of answer input fields, allowing us to focus on them.
  38. * This array is maintained by registerfieldRef and the useEffect below.
  39. */
  40. const answerInputs = useRef<Array<HTMLInputElement>>([]);
  41. const registerFieldRef = useCallback((i, r) => {
  42. if (r === null) {
  43. return;
  44. }
  45. answerInputs.current[i] = r;
  46. }, [ answerInputs ]);
  47. useEffect(() => {
  48. answerInputs.current = answerInputs.current.slice(0, answers.length);
  49. }, [ answers ]);
  50. /*
  51. * This state allows us to requestFocus asynchronously, without having to worry
  52. * about whether a newly created input field has been rendered yet or not.
  53. */
  54. const [ lastFocus, requestFocus ] = useState<number | null>(null);
  55. useEffect(() => {
  56. if (lastFocus === null) {
  57. return;
  58. }
  59. const input = answerInputs.current[lastFocus];
  60. if (input === undefined) {
  61. return;
  62. }
  63. input.focus();
  64. }, [ lastFocus ]);
  65. const checkModifiers = useCallback(ev => {
  66. // Composition events used to add accents to characters
  67. // despite their absence from standard US keyboards,
  68. // to build up logograms of many Asian languages
  69. // from their base components or categories and so on.
  70. if (ev.isComposing || ev.keyCode === 229) {
  71. // keyCode 229 means that user pressed some button,
  72. // but input method is still processing that.
  73. // This is a standard behavior for some input methods
  74. // like entering japanese or сhinese hieroglyphs.
  75. return true;
  76. }
  77. // Because this isn't done automatically on MacOS
  78. if (ev.key === 'Enter' && ev.metaKey) {
  79. ev.preventDefault();
  80. onSubmit();
  81. return;
  82. }
  83. if (ev.ctrlKey || ev.metaKey || ev.altKey || ev.shiftKey) {
  84. return;
  85. }
  86. }, []);
  87. const onQuestionKeyDown = useCallback(ev => {
  88. if (checkModifiers(ev)) {
  89. return;
  90. }
  91. if (ev.key === 'Enter') {
  92. requestFocus(0);
  93. ev.preventDefault();
  94. }
  95. }, []);
  96. // Called on keypress in answer fields
  97. const onAnswerKeyDown = useCallback((i, ev) => {
  98. if (checkModifiers(ev)) {
  99. return;
  100. }
  101. if (ev.key === 'Enter') {
  102. // We add a new option input
  103. // only if we are on the last option input
  104. if (i === answers.length - 1) {
  105. addAnswer(i + 1);
  106. }
  107. requestFocus(i + 1);
  108. ev.preventDefault();
  109. } else if (ev.key === 'Backspace' && ev.target.value === '' && answers.length > 1) {
  110. removeAnswer(i);
  111. requestFocus(i > 0 ? i - 1 : 0);
  112. ev.preventDefault();
  113. } else if (ev.key === 'ArrowDown') {
  114. if (i === answers.length - 1) {
  115. addAnswer();
  116. }
  117. requestFocus(i + 1);
  118. ev.preventDefault();
  119. } else if (ev.key === 'ArrowUp') {
  120. if (i === 0) {
  121. addAnswer(0);
  122. requestFocus(0);
  123. } else {
  124. requestFocus(i - 1);
  125. }
  126. ev.preventDefault();
  127. }
  128. }, [ answers, addAnswer, removeAnswer, requestFocus ]);
  129. const [ grabbing, setGrabbing ] = useState(null);
  130. const interchangeHeights = (i: number, j: number) => {
  131. const h = answerInputs.current[i].scrollHeight;
  132. answerInputs.current[i].style.height = `${answerInputs.current[j].scrollHeight}px`;
  133. answerInputs.current[j].style.height = `${h}px`;
  134. };
  135. const onGrab = useCallback((i, ev) => {
  136. if (ev.button !== 0) {
  137. return;
  138. }
  139. setGrabbing(i);
  140. window.addEventListener('mouseup', () => {
  141. setGrabbing(_grabbing => {
  142. requestFocus(_grabbing);
  143. return null;
  144. });
  145. }, { once: true });
  146. }, []);
  147. const onMouseOver = useCallback(i => {
  148. if (grabbing !== null && grabbing !== i) {
  149. interchangeHeights(i, grabbing);
  150. moveAnswer(grabbing, i);
  151. setGrabbing(i);
  152. }
  153. }, []);
  154. const autogrow = (ev: React.ChangeEvent<HTMLTextAreaElement>) => {
  155. const el = ev.target;
  156. el.style.height = '1px';
  157. el.style.height = `${el.scrollHeight + 2}px`;
  158. };
  159. /* eslint-disable react/jsx-no-bind */
  160. return (<form
  161. className = 'polls-pane-content'
  162. onSubmit = { onSubmit }>
  163. <div className = 'poll-create-container poll-container'>
  164. <div className = 'poll-create-header'>
  165. { t('polls.create.create') }
  166. </div>
  167. <div className = 'poll-question-field'>
  168. <span className = 'poll-create-label'>
  169. { t('polls.create.pollQuestion') }
  170. </span>
  171. <textarea
  172. autoFocus = { true }
  173. className = 'expandable-input'
  174. maxLength = { CHAR_LIMIT }
  175. onChange = { ev => setQuestion(ev.target.value) }
  176. onInput = { autogrow }
  177. onKeyDown = { onQuestionKeyDown }
  178. placeholder = { t('polls.create.questionPlaceholder') }
  179. required = { true }
  180. rows = { 1 }
  181. value = { question } />
  182. </div>
  183. <ol className = 'poll-answer-field-list'>
  184. {answers.map((answer: any, i: number) =>
  185. (<li
  186. className = { `poll-answer-field${grabbing === i ? ' poll-dragged' : ''}` }
  187. key = { i }
  188. onMouseOver = { () => onMouseOver(i) }>
  189. <span className = 'poll-create-label'>
  190. { t('polls.create.pollOption', { index: i + 1 })}
  191. </span>
  192. <div className = 'poll-create-option-row'>
  193. <textarea
  194. className = 'expandable-input'
  195. maxLength = { CHAR_LIMIT }
  196. onChange = { ev => setAnswer(i, ev.target.value) }
  197. onInput = { autogrow }
  198. onKeyDown = { ev => onAnswerKeyDown(i, ev) }
  199. placeholder = { t('polls.create.answerPlaceholder', { index: i + 1 }) }
  200. ref = { r => registerFieldRef(i, r) }
  201. required = { true }
  202. rows = { 1 }
  203. value = { answer } />
  204. <button
  205. className = 'poll-drag-handle'
  206. onMouseDown = { ev => onGrab(i, ev) }
  207. tabIndex = { -1 }
  208. type = 'button'>
  209. <Icon src = { IconBurger } />
  210. </button>
  211. </div>
  212. { answers.length > 2
  213. && <Tooltip content = { t('polls.create.removeOption') }>
  214. <button
  215. className = 'poll-remove-option-button'
  216. onClick = { () => removeAnswer(i) }
  217. type = 'button'>
  218. { t('polls.create.removeOption') }
  219. </button>
  220. </Tooltip>}
  221. </li>)
  222. )}
  223. </ol>
  224. <div className = 'poll-add-button'>
  225. <Button
  226. accessibilityLabel = { t('polls.create.addOption') }
  227. disabled = { answers.length >= ANSWERS_LIMIT }
  228. fullWidth = { true }
  229. labelKey = { 'polls.create.addOption' }
  230. onClick = { () => {
  231. addAnswer();
  232. requestFocus(answers.length);
  233. } }
  234. type = { BUTTON_TYPES.SECONDARY } />
  235. </div>
  236. </div>
  237. <div className = 'poll-footer poll-create-footer'>
  238. <Button
  239. accessibilityLabel = { t('polls.create.cancel') }
  240. className = { styles.buttonMargin }
  241. fullWidth = { true }
  242. labelKey = { 'polls.create.cancel' }
  243. onClick = { () => setCreateMode(false) }
  244. type = { BUTTON_TYPES.SECONDARY } />
  245. <Button
  246. accessibilityLabel = { t('polls.create.send') }
  247. disabled = { isSubmitDisabled }
  248. fullWidth = { true }
  249. isSubmit = { true }
  250. labelKey = { 'polls.create.send' } />
  251. </div>
  252. </form>);
  253. };
  254. /*
  255. * We apply AbstractPollCreate to fill in the AbstractProps common
  256. * to both the web and native implementations.
  257. */
  258. // eslint-disable-next-line new-cap
  259. export default AbstractPollCreate(PollCreate);