inputInline.tsx 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231
  1. import * as React from 'react';
  2. import styled from '@emotion/styled';
  3. import {IconEdit} from 'app/icons';
  4. import space from 'app/styles/space';
  5. import {callIfFunction} from 'app/utils/callIfFunction';
  6. type Props = {
  7. name: string;
  8. className?: string;
  9. style?: React.CSSProperties;
  10. disabled?: boolean;
  11. required?: boolean;
  12. placeholder?: string;
  13. value?: string;
  14. } & React.DOMAttributes<HTMLInputElement>;
  15. type State = {
  16. isFocused: boolean;
  17. isHovering: boolean;
  18. };
  19. /**
  20. * InputInline is a cool pattern and @doralchan has confirmed that this has more
  21. * than 50% chance of being reused elsewhere in the app. However, adding it as a
  22. * form component has too much overhead for Discover2, so it'll be kept outside
  23. * for now.
  24. *
  25. * The props for this component take some cues from InputField.tsx
  26. *
  27. * The implementation uses HTMLDivElement with `contentEditable="true"`. This is
  28. * because we need the width to expand along with the content inside. There
  29. * isn't a way to easily do this with HTMLInputElement, especially with fonts
  30. * which are not fixed-width.
  31. *
  32. * If you are expecting the usual HTMLInputElement, this may have some quirky
  33. * behaviours that'll need your help to improve.
  34. *
  35. * TODO(leedongwei): Add to storybook
  36. * TODO(leedongwei): Add some tests
  37. */
  38. class InputInline extends React.Component<Props, State> {
  39. /**
  40. * HACK(leedongwei): ContentEditable does not have the property `value`. We
  41. * coerce its `innerText` to `value` so it will have similar behaviour as a
  42. * HTMLInputElement
  43. *
  44. * We probably need to attach this to every DOMAttribute event...
  45. */
  46. static setValueOnEvent(
  47. event: React.FormEvent<HTMLDivElement>
  48. ): React.FormEvent<HTMLInputElement> {
  49. const text: string =
  50. (event.target as HTMLDivElement).innerText ||
  51. (event.currentTarget as HTMLDivElement).innerText;
  52. (event.target as HTMLInputElement).value = text;
  53. (event.currentTarget as HTMLInputElement).value = text;
  54. return event as React.FormEvent<HTMLInputElement>;
  55. }
  56. state: State = {
  57. isFocused: false,
  58. isHovering: false,
  59. };
  60. private refInput = React.createRef<HTMLDivElement>();
  61. /**
  62. * Used by the parent to blur/focus on the Input
  63. */
  64. blur = () => {
  65. if (this.refInput.current) {
  66. this.refInput.current.blur();
  67. }
  68. };
  69. /**
  70. * Used by the parent to blur/focus on the Input
  71. */
  72. focus = () => {
  73. if (this.refInput.current) {
  74. this.refInput.current.focus();
  75. document.execCommand('selectAll', false, undefined);
  76. }
  77. };
  78. onBlur = (event: React.FocusEvent<HTMLDivElement>) => {
  79. this.setState({
  80. isFocused: false,
  81. isHovering: false,
  82. });
  83. callIfFunction(this.props.onBlur, InputInline.setValueOnEvent(event));
  84. };
  85. onFocus = (event: React.FocusEvent<HTMLDivElement>) => {
  86. this.setState({isFocused: true});
  87. callIfFunction(this.props.onFocus, InputInline.setValueOnEvent(event));
  88. // Wait for the next event loop so that the content region has focus.
  89. window.setTimeout(() => document.execCommand('selectAll', false, undefined), 1);
  90. };
  91. /**
  92. * HACK(leedongwei): ContentEditable is not a Form element, and as such it
  93. * does not emit `onChange` events. This method using `onInput` and capture the
  94. * inner value to be passed along to an onChange function.
  95. */
  96. onChangeUsingOnInput = (event: React.FormEvent<HTMLDivElement>) => {
  97. callIfFunction(this.props.onChange, InputInline.setValueOnEvent(event));
  98. };
  99. onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
  100. // Might make sense to add Form submission here too
  101. if (event.key === 'Enter') {
  102. // Prevents the Enter key from inserting a line-break
  103. event.preventDefault();
  104. if (this.refInput.current) {
  105. this.refInput.current.blur();
  106. }
  107. }
  108. callIfFunction(this.props.onKeyUp, InputInline.setValueOnEvent(event));
  109. };
  110. onKeyUp = (event: React.KeyboardEvent<HTMLDivElement>) => {
  111. if (event.key === 'Escape' && this.refInput.current) {
  112. this.refInput.current.blur();
  113. }
  114. callIfFunction(this.props.onKeyUp, InputInline.setValueOnEvent(event));
  115. };
  116. onMouseEnter = () => {
  117. this.setState({isHovering: !this.props.disabled});
  118. };
  119. onMouseMove = () => {
  120. this.setState({isHovering: !this.props.disabled});
  121. };
  122. onMouseLeave = () => {
  123. this.setState({isHovering: false});
  124. };
  125. onClickIcon = (event: React.MouseEvent<HTMLDivElement>) => {
  126. if (this.props.disabled) {
  127. return;
  128. }
  129. if (this.refInput.current) {
  130. this.refInput.current.focus();
  131. document.execCommand('selectAll', false, undefined);
  132. }
  133. callIfFunction(this.props.onClick, InputInline.setValueOnEvent(event));
  134. };
  135. render() {
  136. const {value, placeholder, disabled} = this.props;
  137. const {isFocused} = this.state;
  138. const innerText = value || placeholder || '';
  139. return (
  140. <Wrapper
  141. style={this.props.style}
  142. onMouseEnter={this.onMouseEnter}
  143. onMouseMove={this.onMouseMove}
  144. onMouseLeave={this.onMouseLeave}
  145. >
  146. <Input
  147. {...this.props} // Pass DOMAttributes props first, extend/overwrite below
  148. ref={this.refInput}
  149. suppressContentEditableWarning
  150. contentEditable={!this.props.disabled}
  151. isHovering={this.state.isHovering}
  152. isDisabled={this.props.disabled}
  153. onBlur={this.onBlur}
  154. onFocus={this.onFocus}
  155. onInput={this.onChangeUsingOnInput}
  156. onChange={this.onChangeUsingOnInput} // Overwrite onChange too, just to be 100% sure
  157. onKeyDown={this.onKeyDown}
  158. onKeyUp={this.onKeyUp}
  159. >
  160. {innerText}
  161. </Input>
  162. {!isFocused && !disabled && (
  163. <div onClick={this.onClickIcon}>
  164. <StyledIconEdit />
  165. </div>
  166. )}
  167. </Wrapper>
  168. );
  169. }
  170. }
  171. const Wrapper = styled('div')`
  172. display: inline-flex;
  173. align-items: center;
  174. vertical-align: text-bottom;
  175. `;
  176. const Input = styled('div')<{
  177. isHovering?: boolean;
  178. isDisabled?: boolean;
  179. }>`
  180. min-width: 40px;
  181. margin: 0;
  182. border: 1px solid ${p => (p.isHovering ? p.theme.border : 'transparent')};
  183. outline: none;
  184. line-height: inherit;
  185. border-radius: ${space(0.5)};
  186. background: transparent;
  187. padding: 1px;
  188. &:focus,
  189. &:active {
  190. border: 1px solid ${p => (p.isDisabled ? 'transparent' : p.theme.border)};
  191. background-color: ${p => (p.isDisabled ? 'transparent' : p.theme.gray200)};
  192. }
  193. `;
  194. const StyledIconEdit = styled(IconEdit)`
  195. color: ${p => p.theme.gray300};
  196. margin-left: ${space(0.5)};
  197. &:hover {
  198. cursor: pointer;
  199. }
  200. `;
  201. export default InputInline;