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

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