ClosableTextInput.tsx 4.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146
  1. import type { FC } from 'react';
  2. import React, {
  3. memo, useCallback, useEffect, useRef, useState,
  4. } from 'react';
  5. import { useTranslation } from 'next-i18next';
  6. import AutosizeInput from 'react-input-autosize';
  7. import type { AlertInfo } from '~/client/util/input-validator';
  8. import { AlertType, inputValidator } from '~/client/util/input-validator';
  9. export type ClosableTextInputProps = {
  10. value?: string
  11. placeholder?: string
  12. validationTarget?: string,
  13. useAutosizeInput?: boolean
  14. inputClassName?: string,
  15. onPressEnter?(inputText: string): void
  16. onPressEscape?(inputText: string): void
  17. onBlur?(inputText: string): void
  18. onChange?(inputText: string): void
  19. }
  20. const ClosableTextInput: FC<ClosableTextInputProps> = memo((props: ClosableTextInputProps) => {
  21. const { t } = useTranslation();
  22. const {
  23. validationTarget, onPressEnter, onPressEscape, onBlur, onChange,
  24. } = props;
  25. const inputRef = useRef<HTMLInputElement>(null);
  26. const [inputText, setInputText] = useState(props.value ?? '');
  27. const [currentAlertInfo, setAlertInfo] = useState<AlertInfo | null>(null);
  28. const [isAbleToShowAlert, setIsAbleToShowAlert] = useState(false);
  29. const [isComposing, setComposing] = useState(false);
  30. const createValidation = useCallback(async(inputText: string) => {
  31. const alertInfo = await inputValidator(inputText, validationTarget);
  32. if (alertInfo && alertInfo.message != null && alertInfo.target != null) {
  33. alertInfo.message = t(alertInfo.message, { target: t(alertInfo.target) });
  34. }
  35. setAlertInfo(alertInfo);
  36. }, [t, validationTarget]);
  37. const changeHandler = useCallback(async(e: React.ChangeEvent<HTMLInputElement>) => {
  38. const inputText = e.target.value;
  39. createValidation(inputText);
  40. setInputText(inputText);
  41. setIsAbleToShowAlert(true);
  42. onChange?.(inputText);
  43. }, [createValidation, onChange]);
  44. const onFocusHandler = useCallback(async(e: React.ChangeEvent<HTMLInputElement>) => {
  45. const inputText = e.target.value;
  46. await createValidation(inputText);
  47. }, [createValidation]);
  48. const pressEnterHandler = useCallback(() => {
  49. if (currentAlertInfo == null) {
  50. onPressEnter?.(inputText.trim());
  51. }
  52. }, [currentAlertInfo, inputText, onPressEnter]);
  53. const onKeyDownHandler = useCallback((e) => {
  54. switch (e.key) {
  55. case 'Enter':
  56. // Do nothing when composing
  57. if (isComposing) {
  58. return;
  59. }
  60. pressEnterHandler();
  61. break;
  62. case 'Escape':
  63. if (isComposing) {
  64. return;
  65. }
  66. onPressEscape?.(inputText.trim());
  67. break;
  68. default:
  69. break;
  70. }
  71. }, [inputText, isComposing, pressEnterHandler, onPressEscape]);
  72. /*
  73. * Hide when click outside the ref
  74. */
  75. const onBlurHandler = useCallback(() => {
  76. onBlur?.(inputText.trim());
  77. }, [inputText, onBlur]);
  78. // didMount
  79. useEffect(() => {
  80. // autoFocus
  81. if (inputRef?.current == null) {
  82. return;
  83. }
  84. inputRef.current.focus();
  85. });
  86. const AlertInfo = () => {
  87. if (currentAlertInfo == null) {
  88. return <></>;
  89. }
  90. const alertType = currentAlertInfo.type != null ? currentAlertInfo.type : AlertType.ERROR;
  91. const alertMessage = currentAlertInfo.message != null ? currentAlertInfo.message : 'Invalid value';
  92. const alertTextStyle = alertType === AlertType.ERROR ? 'text-danger' : 'text-warning';
  93. const translation = alertType === AlertType.ERROR ? 'Error' : 'Warning';
  94. return (
  95. <p className={`${alertTextStyle} text-center mt-1`}>{t(translation)}: {alertMessage}</p>
  96. );
  97. };
  98. const inputProps = {
  99. 'data-testid': 'closable-text-input',
  100. value: inputText || '',
  101. ref: inputRef,
  102. type: 'text',
  103. placeholder: props.placeholder,
  104. name: 'input',
  105. onFocus: onFocusHandler,
  106. onChange: changeHandler,
  107. onKeyDown: onKeyDownHandler,
  108. onCompositionStart: () => setComposing(true),
  109. onCompositionEnd: () => setComposing(false),
  110. onBlur: onBlurHandler,
  111. };
  112. const inputClassName = `form-control ${props.inputClassName ?? ''}`;
  113. return (
  114. <div>
  115. { props.useAutosizeInput
  116. ? <AutosizeInput inputClassName={inputClassName} {...inputProps} />
  117. : <input className={inputClassName} {...inputProps} />
  118. }
  119. {isAbleToShowAlert && <AlertInfo />}
  120. </div>
  121. );
  122. });
  123. ClosableTextInput.displayName = 'ClosableTextInput';
  124. export default ClosableTextInput;