import {Component, createRef} from 'react'; import styled from '@emotion/styled'; import {IconEdit} from 'sentry/icons'; import space from 'sentry/styles/space'; import {callIfFunction} from 'sentry/utils/callIfFunction'; type Props = { name: string; className?: string; disabled?: boolean; placeholder?: string; required?: boolean; style?: React.CSSProperties; value?: string; } & React.DOMAttributes<HTMLInputElement>; type State = { isFocused: boolean; isHovering: boolean; }; /** * InputInline is a cool pattern and @doralchan has confirmed that this has more * than 50% chance of being reused elsewhere in the app. However, adding it as a * form component has too much overhead for Discover2, so it'll be kept outside * for now. * * The props for this component take some cues from InputField.tsx * * The implementation uses HTMLDivElement with `contentEditable="true"`. This is * because we need the width to expand along with the content inside. There * isn't a way to easily do this with HTMLInputElement, especially with fonts * which are not fixed-width. * * If you are expecting the usual HTMLInputElement, this may have some quirky * behaviours that'll need your help to improve. * * TODO(leedongwei): Add to storybook * TODO(leedongwei): Add some tests */ class InputInline extends Component<Props, State> { /** * HACK(leedongwei): ContentEditable does not have the property `value`. We * coerce its `innerText` to `value` so it will have similar behaviour as a * HTMLInputElement * * We probably need to attach this to every DOMAttribute event... */ static setValueOnEvent( event: React.FormEvent<HTMLDivElement> ): React.FormEvent<HTMLInputElement> { const text: string = (event.target as HTMLDivElement).innerText || (event.currentTarget as HTMLDivElement).innerText; (event.target as HTMLInputElement).value = text; (event.currentTarget as HTMLInputElement).value = text; return event as React.FormEvent<HTMLInputElement>; } state: State = { isFocused: false, isHovering: false, }; componentWillUnmount() { window.clearTimeout(this.onFocusSelectAllTimeout); } onFocusSelectAllTimeout: number | undefined = undefined; private refInput = createRef<HTMLDivElement>(); /** * Used by the parent to blur/focus on the Input */ blur = () => { if (this.refInput.current) { this.refInput.current.blur(); } }; /** * Used by the parent to blur/focus on the Input */ focus = () => { if (this.refInput.current) { this.refInput.current.focus(); document.execCommand('selectAll', false, undefined); } }; onBlur = (event: React.FocusEvent<HTMLDivElement>) => { this.setState({ isFocused: false, isHovering: false, }); callIfFunction(this.props.onBlur, InputInline.setValueOnEvent(event)); }; onFocus = (event: React.FocusEvent<HTMLDivElement>) => { this.setState({isFocused: true}); callIfFunction(this.props.onFocus, InputInline.setValueOnEvent(event)); window.clearTimeout(this.onFocusSelectAllTimeout); // Wait for the next event loop so that the content region has focus. this.onFocusSelectAllTimeout = window.setTimeout( () => document.execCommand('selectAll', false, undefined), 1 ); }; /** * HACK(leedongwei): ContentEditable is not a Form element, and as such it * does not emit `onChange` events. This method using `onInput` and capture the * inner value to be passed along to an onChange function. */ onChangeUsingOnInput = (event: React.FormEvent<HTMLDivElement>) => { callIfFunction(this.props.onChange, InputInline.setValueOnEvent(event)); }; onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => { // Might make sense to add Form submission here too if (event.key === 'Enter') { // Prevents the Enter key from inserting a line-break event.preventDefault(); if (this.refInput.current) { this.refInput.current.blur(); } } callIfFunction(this.props.onKeyUp, InputInline.setValueOnEvent(event)); }; onKeyUp = (event: React.KeyboardEvent<HTMLDivElement>) => { if (event.key === 'Escape' && this.refInput.current) { this.refInput.current.blur(); } callIfFunction(this.props.onKeyUp, InputInline.setValueOnEvent(event)); }; onMouseEnter = () => { this.setState({isHovering: !this.props.disabled}); }; onMouseMove = () => { this.setState({isHovering: !this.props.disabled}); }; onMouseLeave = () => { this.setState({isHovering: false}); }; onClickIcon = (event: React.MouseEvent<HTMLDivElement>) => { if (this.props.disabled) { return; } if (this.refInput.current) { this.refInput.current.focus(); document.execCommand('selectAll', false, undefined); } callIfFunction(this.props.onClick, InputInline.setValueOnEvent(event)); }; render() { const {value, placeholder, disabled} = this.props; const {isFocused} = this.state; const innerText = value || placeholder || ''; return ( <Wrapper style={this.props.style} onMouseEnter={this.onMouseEnter} onMouseMove={this.onMouseMove} onMouseLeave={this.onMouseLeave} > <Input {...this.props} // Pass DOMAttributes props first, extend/overwrite below ref={this.refInput} suppressContentEditableWarning contentEditable={!this.props.disabled} isHovering={this.state.isHovering} isDisabled={this.props.disabled} onBlur={this.onBlur} onFocus={this.onFocus} onInput={this.onChangeUsingOnInput} onChange={this.onChangeUsingOnInput} // Overwrite onChange too, just to be 100% sure onKeyDown={this.onKeyDown} onKeyUp={this.onKeyUp} > {innerText} </Input> {!isFocused && !disabled && ( <div onClick={this.onClickIcon}> <StyledIconEdit /> </div> )} </Wrapper> ); } } const Wrapper = styled('div')` display: inline-flex; align-items: center; vertical-align: text-bottom; `; const Input = styled('div')<{ isDisabled?: boolean; isHovering?: boolean; }>` min-width: 40px; margin: 0; border: 1px solid ${p => (p.isHovering ? p.theme.border : 'transparent')}; outline: none; line-height: inherit; border-radius: ${space(0.5)}; background: transparent; padding: 1px; &:focus, &:active { border: 1px solid ${p => (p.isDisabled ? 'transparent' : p.theme.border)}; background-color: ${p => (p.isDisabled ? 'transparent' : p.theme.gray200)}; } `; const StyledIconEdit = styled(IconEdit)` color: ${p => p.theme.gray300}; margin-left: ${space(0.5)}; &:hover { cursor: pointer; } `; export default InputInline;