Вы не можете выбрать более 25 тем Темы должны начинаться с буквы или цифры, могут содержать дефисы(-) и должны содержать не более 35 символов.

PollCreate.tsx 9.8KB

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