inputInline.tsx 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241
  1. import {Component, createRef} from 'react';
  2. import styled from '@emotion/styled';
  3. import {IconEdit} from 'sentry/icons';
  4. import space from 'sentry/styles/space';
  5. import {callIfFunction} from 'sentry/utils/callIfFunction';
  6. type Props = {
  7. name: string;
  8. className?: string;
  9. disabled?: boolean;
  10. placeholder?: string;
  11. required?: boolean;
  12. style?: React.CSSProperties;
  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 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. componentWillUnmount() {
  61. window.clearTimeout(this.onFocusSelectAllTimeout);
  62. }
  63. onFocusSelectAllTimeout: number | undefined = undefined;
  64. private refInput = createRef<HTMLDivElement>();
  65. /**
  66. * Used by the parent to blur/focus on the Input
  67. */
  68. blur = () => {
  69. if (this.refInput.current) {
  70. this.refInput.current.blur();
  71. }
  72. };
  73. /**
  74. * Used by the parent to blur/focus on the Input
  75. */
  76. focus = () => {
  77. if (this.refInput.current) {
  78. this.refInput.current.focus();
  79. document.execCommand('selectAll', false, undefined);
  80. }
  81. };
  82. onBlur = (event: React.FocusEvent<HTMLDivElement>) => {
  83. this.setState({
  84. isFocused: false,
  85. isHovering: false,
  86. });
  87. callIfFunction(this.props.onBlur, InputInline.setValueOnEvent(event));
  88. };
  89. onFocus = (event: React.FocusEvent<HTMLDivElement>) => {
  90. this.setState({isFocused: true});
  91. callIfFunction(this.props.onFocus, InputInline.setValueOnEvent(event));
  92. window.clearTimeout(this.onFocusSelectAllTimeout);
  93. // Wait for the next event loop so that the content region has focus.
  94. this.onFocusSelectAllTimeout = window.setTimeout(
  95. () => document.execCommand('selectAll', false, undefined),
  96. 1
  97. );
  98. };
  99. /**
  100. * HACK(leedongwei): ContentEditable is not a Form element, and as such it
  101. * does not emit `onChange` events. This method using `onInput` and capture the
  102. * inner value to be passed along to an onChange function.
  103. */
  104. onChangeUsingOnInput = (event: React.FormEvent<HTMLDivElement>) => {
  105. callIfFunction(this.props.onChange, InputInline.setValueOnEvent(event));
  106. };
  107. onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
  108. // Might make sense to add Form submission here too
  109. if (event.key === 'Enter') {
  110. // Prevents the Enter key from inserting a line-break
  111. event.preventDefault();
  112. if (this.refInput.current) {
  113. this.refInput.current.blur();
  114. }
  115. }
  116. callIfFunction(this.props.onKeyUp, InputInline.setValueOnEvent(event));
  117. };
  118. onKeyUp = (event: React.KeyboardEvent<HTMLDivElement>) => {
  119. if (event.key === 'Escape' && this.refInput.current) {
  120. this.refInput.current.blur();
  121. }
  122. callIfFunction(this.props.onKeyUp, InputInline.setValueOnEvent(event));
  123. };
  124. onMouseEnter = () => {
  125. this.setState({isHovering: !this.props.disabled});
  126. };
  127. onMouseMove = () => {
  128. this.setState({isHovering: !this.props.disabled});
  129. };
  130. onMouseLeave = () => {
  131. this.setState({isHovering: false});
  132. };
  133. onClickIcon = (event: React.MouseEvent<HTMLDivElement>) => {
  134. if (this.props.disabled) {
  135. return;
  136. }
  137. if (this.refInput.current) {
  138. this.refInput.current.focus();
  139. document.execCommand('selectAll', false, undefined);
  140. }
  141. callIfFunction(this.props.onClick, InputInline.setValueOnEvent(event));
  142. };
  143. render() {
  144. const {value, placeholder, disabled} = this.props;
  145. const {isFocused} = this.state;
  146. const innerText = value || placeholder || '';
  147. return (
  148. <Wrapper
  149. style={this.props.style}
  150. onMouseEnter={this.onMouseEnter}
  151. onMouseMove={this.onMouseMove}
  152. onMouseLeave={this.onMouseLeave}
  153. >
  154. <Input
  155. {...this.props} // Pass DOMAttributes props first, extend/overwrite below
  156. ref={this.refInput}
  157. suppressContentEditableWarning
  158. contentEditable={!this.props.disabled}
  159. isHovering={this.state.isHovering}
  160. isDisabled={this.props.disabled}
  161. onBlur={this.onBlur}
  162. onFocus={this.onFocus}
  163. onInput={this.onChangeUsingOnInput}
  164. onChange={this.onChangeUsingOnInput} // Overwrite onChange too, just to be 100% sure
  165. onKeyDown={this.onKeyDown}
  166. onKeyUp={this.onKeyUp}
  167. >
  168. {innerText}
  169. </Input>
  170. {!isFocused && !disabled && (
  171. <div onClick={this.onClickIcon}>
  172. <StyledIconEdit />
  173. </div>
  174. )}
  175. </Wrapper>
  176. );
  177. }
  178. }
  179. const Wrapper = styled('div')`
  180. display: inline-flex;
  181. align-items: center;
  182. vertical-align: text-bottom;
  183. `;
  184. const Input = styled('div')<{
  185. isDisabled?: boolean;
  186. isHovering?: boolean;
  187. }>`
  188. min-width: 40px;
  189. margin: 0;
  190. border: 1px solid ${p => (p.isHovering ? p.theme.border : 'transparent')};
  191. outline: none;
  192. line-height: inherit;
  193. border-radius: ${space(0.5)};
  194. background: transparent;
  195. padding: 1px;
  196. &:focus,
  197. &:active {
  198. border: 1px solid ${p => (p.isDisabled ? 'transparent' : p.theme.border)};
  199. background-color: ${p => (p.isDisabled ? 'transparent' : p.theme.gray200)};
  200. }
  201. `;
  202. const StyledIconEdit = styled(IconEdit)`
  203. color: ${p => p.theme.gray300};
  204. margin-left: ${space(0.5)};
  205. &:hover {
  206. cursor: pointer;
  207. }
  208. `;
  209. export default InputInline;