diff --git a/.storybook/preview.js b/.storybook/preview.js index 9ddb43d6f3e7..65508e6bed71 100644 --- a/.storybook/preview.js +++ b/.storybook/preview.js @@ -2,6 +2,7 @@ import React from 'react'; import Onyx from 'react-native-onyx'; import '../assets/css/fonts.css'; import ComposeProviders from '../src/components/ComposeProviders'; +import HTMLEngineProvider from '../src/components/HTMLEngineProvider'; import OnyxProvider from '../src/components/OnyxProvider'; import {LocaleContextProvider} from '../src/components/withLocalize'; import ONYXKEYS from '../src/ONYXKEYS'; @@ -16,6 +17,7 @@ const decorators = [ components={[ OnyxProvider, LocaleContextProvider, + HTMLEngineProvider, ]} > diff --git a/src/components/TextInputFocusable/index.android.js b/src/components/Composer/index.android.js similarity index 91% rename from src/components/TextInputFocusable/index.android.js rename to src/components/Composer/index.android.js index 749a27d899d3..ed23d98020f2 100644 --- a/src/components/TextInputFocusable/index.android.js +++ b/src/components/Composer/index.android.js @@ -39,7 +39,7 @@ const defaultProps = { forwardedRef: null, }; -class TextInputFocusable extends React.Component { +class Composer extends React.Component { componentDidMount() { // This callback prop is used by the parent component using the constructor to // get a ref to the inner textInput element e.g. if we do @@ -76,11 +76,11 @@ class TextInputFocusable extends React.Component { } } -TextInputFocusable.displayName = 'TextInputFocusable'; -TextInputFocusable.propTypes = propTypes; -TextInputFocusable.defaultProps = defaultProps; +Composer.displayName = 'Composer'; +Composer.propTypes = propTypes; +Composer.defaultProps = defaultProps; export default React.forwardRef((props, ref) => ( /* eslint-disable-next-line react/jsx-props-no-spreading */ - + )); diff --git a/src/components/TextInputFocusable/index.ios.js b/src/components/Composer/index.ios.js similarity index 93% rename from src/components/TextInputFocusable/index.ios.js rename to src/components/Composer/index.ios.js index 6955e6813bc5..73784ace874e 100644 --- a/src/components/TextInputFocusable/index.ios.js +++ b/src/components/Composer/index.ios.js @@ -49,7 +49,7 @@ const defaultProps = { }, }; -class TextInputFocusable extends React.Component { +class Composer extends React.Component { componentDidMount() { // This callback prop is used by the parent component using the constructor to // get a ref to the inner textInput element e.g. if we do @@ -88,10 +88,10 @@ class TextInputFocusable extends React.Component { } } -TextInputFocusable.propTypes = propTypes; -TextInputFocusable.defaultProps = defaultProps; +Composer.propTypes = propTypes; +Composer.defaultProps = defaultProps; export default React.forwardRef((props, ref) => ( /* eslint-disable-next-line react/jsx-props-no-spreading */ - + )); diff --git a/src/components/TextInputFocusable/index.js b/src/components/Composer/index.js similarity index 97% rename from src/components/TextInputFocusable/index.js rename to src/components/Composer/index.js index d7d4344faae2..caec71317e84 100755 --- a/src/components/TextInputFocusable/index.js +++ b/src/components/Composer/index.js @@ -99,9 +99,10 @@ const IMAGE_EXTENSIONS = { }; /** + * Enable Markdown parsing. * On web we like to have the Text Input field always focused so the user can easily type a new chat */ -class TextInputFocusable extends React.Component { +class Composer extends React.Component { constructor(props) { super(props); @@ -197,7 +198,6 @@ class TextInputFocusable extends React.Component { * Handles all types of drag-N-drop events on the composer * * @param {Object} e native Event - * @memberof TextInputFocusable */ dragNDropListener(e) { let isOriginComposer = false; @@ -237,7 +237,6 @@ class TextInputFocusable extends React.Component { * Manually place the pasted HTML into Composer * * @param {String} html - pasted HTML - * @memberof TextInputFocusable */ handlePastedHTML(html) { const parser = new ExpensiMark(); @@ -285,14 +284,14 @@ class TextInputFocusable extends React.Component { .then((x) => { const extension = IMAGE_EXTENSIONS[x.type]; if (!extension) { - throw new Error(this.props.translate('textInputFocusable.noExtentionFoundForMimeType')); + throw new Error(this.props.translate('composer.noExtentionFoundForMimeType')); } return new File([x], `pasted_image.${extension}`, {}); }) .then(this.props.onPasteFile) .catch(() => { - const errorDesc = this.props.translate('textInputFocusable.problemGettingImageYouPasted'); + const errorDesc = this.props.translate('composer.problemGettingImageYouPasted'); Growl.error(errorDesc); /* @@ -366,10 +365,10 @@ class TextInputFocusable extends React.Component { } } -TextInputFocusable.propTypes = propTypes; -TextInputFocusable.defaultProps = defaultProps; +Composer.propTypes = propTypes; +Composer.defaultProps = defaultProps; export default withLocalize(React.forwardRef((props, ref) => ( /* eslint-disable-next-line react/jsx-props-no-spreading */ - + ))); diff --git a/src/components/EmojiPicker/EmojiPickerMenu/index.js b/src/components/EmojiPicker/EmojiPickerMenu/index.js index 39e1862a4b46..d23c3193fbcd 100755 --- a/src/components/EmojiPicker/EmojiPickerMenu/index.js +++ b/src/components/EmojiPicker/EmojiPickerMenu/index.js @@ -12,7 +12,7 @@ import themeColors from '../../../styles/themes/default'; import emojis from '../../../../assets/emojis'; import EmojiPickerMenuItem from '../EmojiPickerMenuItem'; import Text from '../../Text'; -import TextInputFocusable from '../../TextInputFocusable'; +import Composer from '../../Composer'; import withWindowDimensions, {windowDimensionsPropTypes} from '../../withWindowDimensions'; import withLocalize, {withLocalizePropTypes} from '../../withLocalize'; import compose from '../../../libs/compose'; @@ -434,7 +434,7 @@ class EmojiPickerMenu extends Component { > {!this.props.isSmallScreenWidth && ( - 0 || props.prefixCharacter; + const value = props.value || props.defaultValue || ''; + const activeLabel = props.forceActiveLabel || value.length > 0 || props.prefixCharacter; this.state = { isFocused: false, @@ -29,6 +29,7 @@ class BaseTextInput extends Component { passwordHidden: props.secureTextEntry, textInputWidth: 0, prefixWidth: 0, + value, }; this.input = null; @@ -61,12 +62,13 @@ class BaseTextInput extends Component { componentDidUpdate() { // Activate or deactivate the label when value is changed programmatically from outside // Only update when value prop is provided - if (this.props.value === undefined || this.value === this.props.value) { + if (this.props.value === undefined || this.state.value === this.props.value) { return; } - this.value = this.props.value; - this.input.setNativeProps({text: this.value}); + // eslint-disable-next-line react/no-did-update-set-state + this.setState({value: this.props.value}); + this.input.setNativeProps({text: this.props.value}); // In some cases, When the value prop is empty, it is not properly updated on the TextInput due to its uncontrolled nature, thus manually clearing the TextInput. if (this.props.value === '') { @@ -125,13 +127,13 @@ class BaseTextInput extends Component { if (this.props.onInputChange) { this.props.onInputChange(value); } - this.value = value; + this.setState({value}); Str.result(this.props.onChangeText, value); this.activateLabel(); } activateLabel() { - if (this.value.length < 0 || this.isLabelActive) { + if (this.state.value.length < 0 || this.isLabelActive) { return; } @@ -143,7 +145,7 @@ class BaseTextInput extends Component { } deactivateLabel() { - if (this.props.forceActiveLabel || this.value.length !== 0 || this.props.prefixCharacter) { + if (this.props.forceActiveLabel || this.state.value.length !== 0 || this.props.prefixCharacter) { return; } @@ -188,6 +190,14 @@ class BaseTextInput extends Component { const hasLabel = Boolean(this.props.label.length); const inputHelpText = this.props.errorText || this.props.hint; const formHelpStyles = this.props.errorText ? styles.formError : styles.formHelp; + const placeholder = (this.props.prefixCharacter || this.state.isFocused || !hasLabel || (hasLabel && this.props.forceActiveLabel)) ? this.props.placeholder : null; + const textInputContainerStyles = _.reduce([ + styles.textInputContainer, + ...this.props.textInputContainerStyles, + this.props.autoGrow && StyleUtils.getAutoGrowTextInputStyle(this.state.textInputWidth), + !this.props.hideFocusedState && this.state.isFocused && styles.borderColorFocus, + (this.props.hasError || this.props.errorText) && styles.borderColorDanger, + ], (finalStyles, s) => ({...finalStyles, ...s}), {}); return ( <> @@ -201,11 +211,10 @@ class BaseTextInput extends Component { {hasLabel ? ( @@ -242,8 +251,8 @@ class BaseTextInput extends Component { }} // eslint-disable-next-line {...inputProps} - defaultValue={this.value} - placeholder={(this.props.prefixCharacter || this.state.isFocused || !this.props.label) ? this.props.placeholder : null} + defaultValue={this.state.value} + placeholder={placeholder} placeholderTextColor={themeColors.placeholderText} underlineColorAndroid="transparent" style={[ @@ -293,7 +302,7 @@ class BaseTextInput extends Component { )} {!_.isNull(this.props.maxLength) && ( - {this.value.length} + {this.state.value.length} / {this.props.maxLength} @@ -309,10 +318,10 @@ class BaseTextInput extends Component { */} {this.props.autoGrow && ( this.setState({textInputWidth: e.nativeEvent.layout.width})} > - {this.props.value || this.props.placeholder} + {this.state.value || this.props.placeholder} )} diff --git a/src/components/TextInput/baseTextInputPropTypes.js b/src/components/TextInput/baseTextInputPropTypes.js index e6a612d73205..89ea048cd35d 100644 --- a/src/components/TextInput/baseTextInputPropTypes.js +++ b/src/components/TextInput/baseTextInputPropTypes.js @@ -57,7 +57,6 @@ const propTypes = { prefixCharacter: PropTypes.string, /** Form props */ - /** Indicates that the input is being used with the Form component */ isFormInput: PropTypes.bool, diff --git a/src/languages/en.js b/src/languages/en.js index 2e1deb369b4e..4934a3c606da 100755 --- a/src/languages/en.js +++ b/src/languages/en.js @@ -111,7 +111,7 @@ export default { attachmentTooLarge: 'Attachment too large', sizeExceeded: 'Attachment size is larger than 50 MB limit.', }, - textInputFocusable: { + composer: { noExtentionFoundForMimeType: 'No extension found for mime type', problemGettingImageYouPasted: 'There was a problem getting the image you pasted', }, diff --git a/src/languages/es.js b/src/languages/es.js index 93211173aa18..7fa4f64abaa1 100644 --- a/src/languages/es.js +++ b/src/languages/es.js @@ -111,7 +111,7 @@ export default { attachmentTooLarge: 'Archivo adjunto demasiado grande', sizeExceeded: 'El archivo adjunto supera el límite de 50 MB.', }, - textInputFocusable: { + composer: { noExtentionFoundForMimeType: 'No se encontró una extension para este tipo de contenido', problemGettingImageYouPasted: 'Ha ocurrido un problema al obtener la imagen que has pegado', }, diff --git a/src/pages/home/report/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose.js index cafee70c3b85..15adf07f609e 100755 --- a/src/pages/home/report/ReportActionCompose.js +++ b/src/pages/home/report/ReportActionCompose.js @@ -12,7 +12,7 @@ import {withOnyx} from 'react-native-onyx'; import lodashIntersection from 'lodash/intersection'; import styles from '../../../styles/styles'; import themeColors from '../../../styles/themes/default'; -import TextInputFocusable from '../../../components/TextInputFocusable'; +import Composer from '../../../components/Composer'; import ONYXKEYS from '../../../ONYXKEYS'; import Icon from '../../../components/Icon'; import * as Expensicons from '../../../components/Icon/Expensicons'; @@ -510,7 +510,7 @@ class ReportActionCompose extends React.Component { )} - - { this.textInput = el; diff --git a/src/stories/Composer.stories.js b/src/stories/Composer.stories.js new file mode 100644 index 000000000000..6e3be856668c --- /dev/null +++ b/src/stories/Composer.stories.js @@ -0,0 +1,120 @@ +import ExpensiMark from 'expensify-common/lib/ExpensiMark'; +import React, {useState} from 'react'; +import lodashGet from 'lodash/get'; +import {View, Image} from 'react-native'; +import Composer from '../components/Composer'; +import RenderHTML from '../components/RenderHTML'; +import Text from '../components/Text'; +import styles from '../styles/styles'; +import themeColors from '../styles/themes/default'; +import * as StyleUtils from '../styles/StyleUtils'; +import CONST from '../CONST'; + +/** + * We use the Component Story Format for writing stories. Follow the docs here: + * + * https://storybook.js.org/docs/react/writing-stories/introduction#component-story-format + */ +const story = { + title: 'Components/Composer', + component: Composer, +}; + +const parser = new ExpensiMark(); + +const Default = (args) => { + const [pastedFile, setPastedFile] = useState(null); + const [comment, setComment] = useState(args.defaultValue); + const [droppingFile, setDroppingFile] = useState(false); + const [isComposerDroppingTarget, setIsComposerDroppingTarget] = useState(false); + const renderedHTML = parser.replace(comment); + + return ( + + + { + setIsComposerDroppingTarget(isOriginComposer); + setDroppingFile(true); + }} + onDragLeave={() => { + setIsComposerDroppingTarget(false); + setDroppingFile(false); + }} + onDrop={(e) => { + e.preventDefault(); + + const file = lodashGet(e, ['dataTransfer', 'files', 0]); + if (!file) { + return; + } + + setPastedFile(file); + }} + onPasteFile={setPastedFile} + style={[styles.textInputCompose, styles.w100]} + /> + + + + Entered Comment (Drop Enabled) + {comment} + + + Rendered Comment + {Boolean(renderedHTML) && } + {pastedFile && ( + + + + )} + + + + ); +}; + +Default.args = { + autoFocus: true, + placeholder: 'Compose Text Here', + placeholderTextColor: themeColors.placeholderText, + defaultValue: `Composer can do the following: + + * It can contain MD e.g. *bold* _italic_ + * Supports Pasted Images via Ctrl+V + * Supports Drag N Drop for files.`, + isDisabled: false, + maxLines: 16, +}; + +export default story; +export { + Default, +}; diff --git a/src/stories/TextInput.stories.js b/src/stories/TextInput.stories.js index 88c216148b84..7472f8bc2848 100644 --- a/src/stories/TextInput.stories.js +++ b/src/stories/TextInput.stories.js @@ -1,4 +1,4 @@ -import React from 'react'; +import React, {useState} from 'react'; import TextInput from '../components/TextInput'; /** @@ -24,45 +24,101 @@ AutoFocus.args = { autoFocus: true, }; -const Default = Template.bind({}); -Default.args = { +const DefaultInput = Template.bind({}); +DefaultInput.args = { label: 'Default text input', name: 'Default', }; -const DefaultValue = Template.bind({}); -DefaultValue.args = { - label: 'Input with default value', +const DefaultValueInput = Template.bind({}); +DefaultValueInput.args = { + label: 'Default value input', name: 'DefaultValue', defaultValue: 'My default value', }; -const ErrorStory = Template.bind({}); -ErrorStory.args = { - label: 'Input with error', +const ErrorInput = Template.bind({}); +ErrorInput.args = { + label: 'Error input', name: 'InputWithError', - errorText: 'This field has an error.', + errorText: 'Oops! Looks like there\'s an error', }; const ForceActiveLabel = Template.bind({}); ForceActiveLabel.args = { - label: 'Forced active label', + label: 'Force active label', + placeholder: 'My placeholder text', forceActiveLabel: true, }; -const Placeholder = Template.bind({}); -Placeholder.args = { - label: 'Input with placeholder', +const PlaceholderInput = Template.bind({}); +PlaceholderInput.args = { + label: 'Placeholder input', name: 'Placeholder', placeholder: 'My placeholder text', }; +const AutoGrowInput = Template.bind({}); +AutoGrowInput.args = { + label: 'Autogrow input', + name: 'AutoGrow', + placeholder: 'My placeholder text', + autoGrow: true, + textInputContainerStyles: [{ + minWidth: 150, + }], +}; + +const PrefixedInput = Template.bind({}); +PrefixedInput.args = { + label: 'Prefixed input', + name: 'Prefixed', + placeholder: 'My placeholder text', + prefixCharacter: '@', +}; + +const MaxLengthInput = Template.bind({}); +MaxLengthInput.args = { + label: 'MaxLength input', + name: 'MaxLength', + placeholder: 'My placeholder text', + maxLength: 50, +}; + +const HintAndErrorInput = (args) => { + const [error, setError] = useState(''); + return ( + { + if (value && value.toLowerCase() === 'oops!') { + setError('Oops! Looks like there\'s an error'); + return; + } + setError(''); + }} + errorText={error} + /> + ); +}; +HintAndErrorInput.args = { + label: 'HintAndError input', + name: 'HintAndError', + placeholder: 'My placeholder text', + hint: 'Type "Oops!" to see the error', +}; + export default story; export { AutoFocus, - Default, - DefaultValue, - ErrorStory, + DefaultInput, + DefaultValueInput, + ErrorInput, ForceActiveLabel, - Placeholder, + PlaceholderInput, + AutoGrowInput, + PrefixedInput, + MaxLengthInput, + HintAndErrorInput, }; diff --git a/src/styles/StyleUtils.js b/src/styles/StyleUtils.js index f16a391257ec..bc03ef314d7b 100644 --- a/src/styles/StyleUtils.js +++ b/src/styles/StyleUtils.js @@ -182,7 +182,6 @@ function getZoomSizingStyle(isZoomed, imgWidth, imgHeight, zoomScale, containerH */ function getAutoGrowTextInputStyle(width) { return { - minWidth: 5, width, }; } diff --git a/src/styles/styles.js b/src/styles/styles.js index a41ba1c99f9c..f78e815d0f2a 100644 --- a/src/styles/styles.js +++ b/src/styles/styles.js @@ -480,6 +480,10 @@ const styles = { height: 0, }, + visibilityHidden: { + ...visibility('hidden'), + }, + loadingVBAAnimation: { width: 160, height: 160, diff --git a/src/styles/utilities/sizing.js b/src/styles/utilities/sizing.js index 06309cedb0f8..52d4520a1292 100644 --- a/src/styles/utilities/sizing.js +++ b/src/styles/utilities/sizing.js @@ -16,6 +16,10 @@ export default { minHeight: '100%', }, + mnw2: { + minWidth: 8, + }, + w50: { width: '50%', },