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 6d77a2388f38..4fe540bcc813 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';
@@ -432,7 +432,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 === '') {
@@ -124,13 +126,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;
}
@@ -142,7 +144,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;
}
@@ -187,6 +189,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 (
<>
@@ -200,11 +210,10 @@ class BaseTextInput extends Component {
{hasLabel ? (
@@ -241,8 +250,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={[
@@ -292,7 +301,7 @@ class BaseTextInput extends Component {
)}
{!_.isNull(this.props.maxLength) && (
- {this.value.length}
+ {this.state.value.length}
/
{this.props.maxLength}
@@ -308,10 +317,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 be59d51be83c..56aab993560f 100755
--- a/src/languages/en.js
+++ b/src/languages/en.js
@@ -112,7 +112,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 63e328dffb5c..d6b40f3495c5 100644
--- a/src/languages/es.js
+++ b/src/languages/es.js
@@ -112,7 +112,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 22fdf7304179..166ae5356f8a 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';
@@ -514,7 +514,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%',
},