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 9.3KB

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