diff --git a/src/components/ExpensiTextInput/BaseExpensiTextInput.js b/src/components/ExpensiTextInput/BaseExpensiTextInput.js index c5fcd6b3d42c..9394a8d9f88d 100644 --- a/src/components/ExpensiTextInput/BaseExpensiTextInput.js +++ b/src/components/ExpensiTextInput/BaseExpensiTextInput.js @@ -1,7 +1,7 @@ import _ from 'underscore'; import React, {Component} from 'react'; import { - Animated, TextInput, View, TouchableWithoutFeedback, Pressable, + Animated, View, TouchableWithoutFeedback, Pressable, } from 'react-native'; import Str from 'expensify-common/lib/str'; import ExpensiTextInputLabel from './ExpensiTextInputLabel'; @@ -12,6 +12,7 @@ import Icon from '../Icon'; import * as Expensicons from '../Icon/Expensicons'; import InlineErrorText from '../InlineErrorText'; import * as styleConst from './styleConst'; +import TextInputWithName from '../TextInputWithName'; class BaseExpensiTextInput extends Component { constructor(props) { @@ -167,11 +168,12 @@ class BaseExpensiTextInput extends Component { label={this.props.label} labelTranslateY={this.state.labelTranslateY} labelScale={this.state.labelScale} + for={this.props.nativeID} /> ) : null} - { if (typeof this.props.innerRef === 'function') { this.props.innerRef(ref); } this.input = ref; @@ -189,18 +191,19 @@ class BaseExpensiTextInput extends Component { onChangeText={this.setValue} secureTextEntry={this.state.passwordHidden} onPressOut={this.props.onPress} + name={this.props.name} /> {this.props.secureTextEntry && ( - - - + + + )} diff --git a/src/components/ExpensiTextInput/ExpensiTextInputLabel/expensiTextInputLabelPropTypes.js b/src/components/ExpensiTextInput/ExpensiTextInputLabel/expensiTextInputLabelPropTypes.js index 3de0e9bbfe78..775605b93fc5 100644 --- a/src/components/ExpensiTextInput/ExpensiTextInputLabel/expensiTextInputLabelPropTypes.js +++ b/src/components/ExpensiTextInput/ExpensiTextInputLabel/expensiTextInputLabelPropTypes.js @@ -3,13 +3,23 @@ import {Animated} from 'react-native'; const propTypes = { /** Label */ - label: PropTypes.string, + label: PropTypes.string.isRequired, /** Label vertical translate */ labelTranslateY: PropTypes.instanceOf(Animated.Value).isRequired, /** Label scale */ labelScale: PropTypes.instanceOf(Animated.Value).isRequired, + + /** For attribute for label */ + for: PropTypes.string, +}; + +const defaultProps = { + for: '', }; -export default propTypes; +export { + propTypes, + defaultProps, +}; diff --git a/src/components/ExpensiTextInput/ExpensiTextInputLabel/index.js b/src/components/ExpensiTextInput/ExpensiTextInputLabel/index.js index 6117714370a4..208f6271be9e 100644 --- a/src/components/ExpensiTextInput/ExpensiTextInputLabel/index.js +++ b/src/components/ExpensiTextInput/ExpensiTextInputLabel/index.js @@ -1,26 +1,39 @@ -import React, {memo} from 'react'; +import React, {PureComponent} from 'react'; import {Animated} from 'react-native'; import styles from '../../../styles/styles'; -import propTypes from './expensiTextInputLabelPropTypes'; +import {propTypes, defaultProps} from './expensiTextInputLabelPropTypes'; -const ExpensiTextInputLabel = props => ( - - {props.label} - -); +class ExpensiTextInputLabel extends PureComponent { + componentDidMount() { + if (!this.props.for) { + return; + } + this.label.setNativeProps({for: this.props.for}); + } + + render() { + return ( + this.label = el} + style={[ + styles.expensiTextInputLabel, + styles.expensiTextInputLabelDesktop, + styles.expensiTextInputLabelTransformation( + this.props.labelTranslateY, + 0, + this.props.labelScale, + ), + ]} + > + {this.props.label} + + ); + } +} ExpensiTextInputLabel.propTypes = propTypes; -ExpensiTextInputLabel.displayName = 'ExpensiTextInputLabel'; +ExpensiTextInputLabel.defaultProps = defaultProps; -export default memo(ExpensiTextInputLabel); +export default ExpensiTextInputLabel; diff --git a/src/components/ExpensiTextInput/ExpensiTextInputLabel/index.native.js b/src/components/ExpensiTextInput/ExpensiTextInputLabel/index.native.js index e249f89e1b52..b2c2abcff185 100644 --- a/src/components/ExpensiTextInput/ExpensiTextInputLabel/index.native.js +++ b/src/components/ExpensiTextInput/ExpensiTextInputLabel/index.native.js @@ -1,7 +1,7 @@ import React, {PureComponent} from 'react'; import {Animated} from 'react-native'; import styles from '../../../styles/styles'; -import propTypes from './expensiTextInputLabelPropTypes'; +import * as expensiTextInputLabelPropTypes from './expensiTextInputLabelPropTypes'; import * as styleConst from '../styleConst'; class ExpensiTextInputLabel extends PureComponent { @@ -36,6 +36,7 @@ class ExpensiTextInputLabel extends PureComponent { } } -ExpensiTextInputLabel.propTypes = propTypes; +ExpensiTextInputLabel.propTypes = expensiTextInputLabelPropTypes.propTypes; +ExpensiTextInputLabel.defaultProps = expensiTextInputLabelPropTypes.defaultProps; export default ExpensiTextInputLabel; diff --git a/src/components/ExpensiTextInput/baseExpensiTextInputPropTypes.js b/src/components/ExpensiTextInput/baseExpensiTextInputPropTypes.js index f3cdbae2ca94..6023b6d31d9c 100644 --- a/src/components/ExpensiTextInput/baseExpensiTextInputPropTypes.js +++ b/src/components/ExpensiTextInput/baseExpensiTextInputPropTypes.js @@ -4,6 +4,9 @@ const propTypes = { /** Input label */ label: PropTypes.string, + /** Name attribute for the input */ + name: PropTypes.string, + /** Input value */ value: PropTypes.string, @@ -33,6 +36,7 @@ const propTypes = { const defaultProps = { label: '', + name: '', errorText: '', placeholder: '', hasError: false, diff --git a/src/components/Form/BaseForm.js b/src/components/Form/BaseForm.js new file mode 100644 index 000000000000..21a4e19b1f9b --- /dev/null +++ b/src/components/Form/BaseForm.js @@ -0,0 +1,16 @@ +import React, {forwardRef} from 'react'; +import {View} from 'react-native'; +import * as ComponentUtils from '../../libs/ComponentUtils'; + +const BaseForm = forwardRef((props, ref) => ( + +)); + +BaseForm.displayName = 'BaseForm'; +export default BaseForm; diff --git a/src/components/Form/index.js b/src/components/Form/index.js new file mode 100644 index 000000000000..163f1ee223b1 --- /dev/null +++ b/src/components/Form/index.js @@ -0,0 +1,28 @@ +import React from 'react'; +import BaseForm from './BaseForm'; + +class Form extends React.Component { + componentDidMount() { + if (!this.form) { + return; + } + + // Password Managers need these attributes to be able to identify the form elements properly. + this.form.setNativeProps({ + method: 'post', + action: '/', + }); + } + + render() { + return ( + this.form = el} + // eslint-disable-next-line react/jsx-props-no-spreading + {...this.props} + /> + ); + } +} + +export default Form; diff --git a/src/components/Form/index.native.js b/src/components/Form/index.native.js new file mode 100644 index 000000000000..21f10e7a428d --- /dev/null +++ b/src/components/Form/index.native.js @@ -0,0 +1,8 @@ +import React from 'react'; +import BaseForm from './BaseForm'; + +// eslint-disable-next-line react/jsx-props-no-spreading +const Form = props => ; + +Form.displayName = 'Form'; +export default Form; diff --git a/src/components/TextInputWithName/index.js b/src/components/TextInputWithName/index.js new file mode 100755 index 000000000000..5a78089af609 --- /dev/null +++ b/src/components/TextInputWithName/index.js @@ -0,0 +1,40 @@ +import _ from 'underscore'; +import React from 'react'; +import {TextInput} from 'react-native'; +import textInputWithNamepropTypes from './textInputWithNamepropTypes'; + +/** + * On web we need to set the native attribute name for accessiblity. + */ +class TextInputWithName extends React.Component { + componentDidMount() { + if (!this.textInput) { + return; + } + if (_.isFunction(this.props.forwardedRef)) { + this.props.forwardedRef(this.textInput); + } + + if (this.props.name) { + this.textInput.setNativeProps({name: this.props.name}); + } + } + + render() { + return ( + this.textInput = el} + // eslint-disable-next-line react/jsx-props-no-spreading + {...this.props} + /> + ); + } +} + +TextInputWithName.propTypes = textInputWithNamepropTypes.propTypes; +TextInputWithName.defaultProps = textInputWithNamepropTypes.defaultProps; + +export default React.forwardRef((props, ref) => ( + /* eslint-disable-next-line react/jsx-props-no-spreading */ + +)); diff --git a/src/components/TextInputWithName/index.native.js b/src/components/TextInputWithName/index.native.js new file mode 100644 index 000000000000..198c9540dc2f --- /dev/null +++ b/src/components/TextInputWithName/index.native.js @@ -0,0 +1,20 @@ +import React from 'react'; +import {TextInput} from 'react-native'; +import textInputWithNamepropTypes from './textInputWithNamepropTypes'; + +const TextInputWithName = props => ( + +); + +TextInputWithName.propTypes = textInputWithNamepropTypes.propTypes; +TextInputWithName.defaultProps = textInputWithNamepropTypes.defaultProps; +TextInputWithName.displayName = 'TextInputWithName'; + +export default React.forwardRef((props, ref) => ( + /* eslint-disable-next-line react/jsx-props-no-spreading */ + +)); diff --git a/src/components/TextInputWithName/textInputWithNamepropTypes.js b/src/components/TextInputWithName/textInputWithNamepropTypes.js new file mode 100644 index 000000000000..902ff2289d68 --- /dev/null +++ b/src/components/TextInputWithName/textInputWithNamepropTypes.js @@ -0,0 +1,19 @@ +import PropTypes from 'prop-types'; + +const propTypes = { + /** Name attribute for the input */ + name: PropTypes.string, + + /** A ref to forward to the text input */ + forwardedRef: PropTypes.func, +}; + +const defaultProps = { + name: '', + forwardedRef: () => {}, +}; + +export default { + propTypes, + defaultProps, +}; diff --git a/src/components/withToggleVisibilityView.js b/src/components/withToggleVisibilityView.js new file mode 100644 index 000000000000..3a98b09c205b --- /dev/null +++ b/src/components/withToggleVisibilityView.js @@ -0,0 +1,46 @@ +import React from 'react'; +import {View} from 'react-native'; +import PropTypes from 'prop-types'; +import styles from '../styles/styles'; +import getComponentDisplayName from '../libs/getComponentDisplayName'; + +const toggleVisibilityViewPropTypes = { + /** Whether the content is visible. */ + isVisible: PropTypes.bool, +}; + +export default function (WrappedComponent) { + const WithToggleVisibilityView = props => ( + + + + ); + + WithToggleVisibilityView.displayName = `WithToggleVisibilityView(${getComponentDisplayName(WrappedComponent)})`; + WithToggleVisibilityView.propTypes = { + forwardedRef: PropTypes.oneOfType([ + PropTypes.func, + PropTypes.shape({current: PropTypes.instanceOf(React.Component)}), + ]), + + /** Whether the content is visible. */ + isVisible: PropTypes.bool, + }; + WithToggleVisibilityView.defaultProps = { + forwardedRef: undefined, + isVisible: false, + }; + return React.forwardRef((props, ref) => ( + // eslint-disable-next-line react/jsx-props-no-spreading + + )); +} + +export { + toggleVisibilityViewPropTypes, +}; diff --git a/src/libs/ComponentUtils/index.js b/src/libs/ComponentUtils/index.js new file mode 100644 index 000000000000..f3b1b07f07d8 --- /dev/null +++ b/src/libs/ComponentUtils/index.js @@ -0,0 +1,10 @@ +/** + * Web password field needs `current-password` as autocomplete type which is not supported on native + */ +const PASSWORD_AUTOCOMPLETE_TYPE = 'current-password'; +const ACCESSIBILITY_ROLE_FORM = 'form'; + +export { + PASSWORD_AUTOCOMPLETE_TYPE, + ACCESSIBILITY_ROLE_FORM, +}; diff --git a/src/libs/ComponentUtils/index.native.js b/src/libs/ComponentUtils/index.native.js new file mode 100644 index 000000000000..fcde8612a2f9 --- /dev/null +++ b/src/libs/ComponentUtils/index.native.js @@ -0,0 +1,7 @@ +const PASSWORD_AUTOCOMPLETE_TYPE = 'password'; +const ACCESSIBILITY_ROLE_FORM = 'none'; + +export { + PASSWORD_AUTOCOMPLETE_TYPE, + ACCESSIBILITY_ROLE_FORM, +}; diff --git a/src/pages/signin/ChangeExpensifyLoginLink.js b/src/pages/signin/ChangeExpensifyLoginLink.js index f600438ec401..9469828a21e6 100755 --- a/src/pages/signin/ChangeExpensifyLoginLink.js +++ b/src/pages/signin/ChangeExpensifyLoginLink.js @@ -16,11 +16,17 @@ const propTypes = { credentials: PropTypes.shape({ /** The email the user logged in with */ login: PropTypes.string, - }).isRequired, + }), ...withLocalizePropTypes, }; +const defaultProps = { + credentials: { + login: '', + }, +}; + const ChangeExpensifyLoginLink = props => ( @@ -45,6 +51,7 @@ const ChangeExpensifyLoginLink = props => ( ); ChangeExpensifyLoginLink.propTypes = propTypes; +ChangeExpensifyLoginLink.defaultProps = defaultProps; ChangeExpensifyLoginLink.displayName = 'ChangeExpensifyLoginLink'; export default compose( diff --git a/src/pages/signin/LoginForm.js b/src/pages/signin/LoginForm.js index c791b556575b..f70ccbb0a4de 100755 --- a/src/pages/signin/LoginForm.js +++ b/src/pages/signin/LoginForm.js @@ -17,6 +17,7 @@ import getEmailKeyboardType from '../../libs/getEmailKeyboardType'; import ExpensiTextInput from '../../components/ExpensiTextInput'; import * as ValidationUtils from '../../libs/ValidationUtils'; import LoginUtil from '../../libs/LoginUtil'; +import withToggleVisibilityView, {toggleVisibilityViewPropTypes} from '../../components/withToggleVisibilityView'; const propTypes = { /* Onyx Props */ @@ -36,6 +37,8 @@ const propTypes = { ...windowDimensionsPropTypes, ...withLocalizePropTypes, + + ...toggleVisibilityViewPropTypes, }; const defaultProps = { @@ -45,7 +48,6 @@ const defaultProps = { class LoginForm extends React.Component { constructor(props) { super(props); - this.onTextInput = this.onTextInput.bind(this); this.validateAndSubmitForm = this.validateAndSubmitForm.bind(this); @@ -55,6 +57,24 @@ class LoginForm extends React.Component { }; } + componentDidMount() { + if (!canFocusInputOnScreenFocus() || !this.input) { + return; + } + this.input.focus(); + } + + componentDidUpdate(prevProps) { + if (prevProps.isVisible || !this.props.isVisible) { + return; + } + this.input.focus(); + + if (this.state.login) { + this.clearLogin(); + } + } + /** * Handle text input and clear formError upon text change * @@ -71,6 +91,13 @@ class LoginForm extends React.Component { } } + /** + * Clear Login from the state + */ + clearLogin() { + this.setState({login: ''}, this.input.clear); + } + /** * Check that all the form fields are valid, then trigger the submit callback */ @@ -105,16 +132,18 @@ class LoginForm extends React.Component { <> this.input = el} label={this.props.translate('loginForm.phoneOrEmail')} value={this.state.login} - autoCompleteType="email" + autoCompleteType="username" textContentType="username" + nativeID="username" + name="username" onChangeText={this.onTextInput} onSubmitEditing={this.validateAndSubmitForm} autoCapitalize="none" autoCorrect={false} keyboardType={getEmailKeyboardType()} - autoFocus={canFocusInputOnScreenFocus()} /> {this.state.formError && ( @@ -141,7 +170,6 @@ class LoginForm extends React.Component { onPress={this.validateAndSubmitForm} /> - ); } @@ -156,4 +184,5 @@ export default compose( }), withWindowDimensions, withLocalize, + withToggleVisibilityView, )(LoginForm); diff --git a/src/pages/signin/PasswordForm.js b/src/pages/signin/PasswordForm.js index 391130bf56b4..7b73a5937ee2 100755 --- a/src/pages/signin/PasswordForm.js +++ b/src/pages/signin/PasswordForm.js @@ -16,6 +16,8 @@ import ChangeExpensifyLoginLink from './ChangeExpensifyLoginLink'; import withLocalize, {withLocalizePropTypes} from '../../components/withLocalize'; import compose from '../../libs/compose'; import ExpensiTextInput from '../../components/ExpensiTextInput'; +import * as ComponentUtils from '../../libs/ComponentUtils'; +import withToggleVisibilityView, {toggleVisibilityViewPropTypes} from '../../components/withToggleVisibilityView'; const propTypes = { /* Onyx Props */ @@ -33,6 +35,7 @@ const propTypes = { }), ...withLocalizePropTypes, + ...toggleVisibilityViewPropTypes, }; const defaultProps = { @@ -42,7 +45,6 @@ const defaultProps = { class PasswordForm extends React.Component { constructor(props) { super(props); - this.validateAndSubmitForm = this.validateAndSubmitForm.bind(this); this.state = { @@ -52,6 +54,20 @@ class PasswordForm extends React.Component { }; } + componentDidMount() { + if (!this.input) { + return; + } + this.input.focus(); + } + + componentDidUpdate(prevProps) { + if (prevProps.isVisible || !this.props.isVisible) { + return; + } + this.input.focus(); + } + /** * Check that all the form fields are valid, then trigger the submit callback */ @@ -83,14 +99,16 @@ class PasswordForm extends React.Component { <> this.input = el} label={this.props.translate('common.password')} secureTextEntry - autoCompleteType="password" + autoCompleteType={ComponentUtils.PASSWORD_AUTOCOMPLETE_TYPE} textContentType="password" + nativeID="password" + name="password" value={this.state.password} onChangeText={text => this.setState({password: text})} onSubmitEditing={this.validateAndSubmitForm} - autoFocus blurOnSubmit={false} /> @@ -155,4 +173,5 @@ export default compose( withOnyx({ account: {key: ONYXKEYS.ACCOUNT}, }), + withToggleVisibilityView, )(PasswordForm); diff --git a/src/pages/signin/SignInPage.js b/src/pages/signin/SignInPage.js index 407f757c7aac..538abbf79b2c 100644 --- a/src/pages/signin/SignInPage.js +++ b/src/pages/signin/SignInPage.js @@ -81,21 +81,19 @@ class SignInPage extends Component { const welcomeText = this.props.translate(`welcomeText.${showPasswordForm ? 'phrase4' : 'phrase1'}`); return ( - <> - - - {showLoginForm && } - - {showPasswordForm && } - - {showResendValidationLinkForm && } - - - + + + {/* LoginForm and PasswordForm must use the isVisible prop. This keeps them mounted, but visually hidden + so that password managers can access the values. Conditionally rendering these components will break this feature. */} + + + {showResendValidationLinkForm && } + + ); } } diff --git a/src/pages/signin/SignInPageLayout/SignInPageLayoutNarrow.js b/src/pages/signin/SignInPageLayout/SignInPageLayoutNarrow.js index 381f02273d39..dc2ad6f1aaa1 100755 --- a/src/pages/signin/SignInPageLayout/SignInPageLayoutNarrow.js +++ b/src/pages/signin/SignInPageLayout/SignInPageLayoutNarrow.js @@ -9,6 +9,7 @@ import ExpensifyCashLogo from '../../../components/ExpensifyCashLogo'; import Text from '../../../components/Text'; import TermsAndLicenses from '../TermsAndLicenses'; import withLocalize, {withLocalizePropTypes} from '../../../components/withLocalize'; +import Form from '../../../components/Form'; import compose from '../../../libs/compose'; import scrollViewContentContainerStyles from './signInPageStyles.js'; import LoginKeyboardAvoidingView from './LoginKeyboardAvoidingView'; @@ -45,7 +46,7 @@ const SignInPageLayoutNarrow = props => ( ]} contentContainerStyle={scrollViewContentContainerStyles} > - +
( )} {props.children} - +
diff --git a/src/pages/signin/SignInPageLayout/SignInPageLayoutWide.js b/src/pages/signin/SignInPageLayout/SignInPageLayoutWide.js index 065fb892a415..f2cf7680629a 100755 --- a/src/pages/signin/SignInPageLayout/SignInPageLayoutWide.js +++ b/src/pages/signin/SignInPageLayout/SignInPageLayoutWide.js @@ -9,6 +9,7 @@ import Text from '../../../components/Text'; import variables from '../../../styles/variables'; import TermsAndLicenses from '../TermsAndLicenses'; import withLocalize, {withLocalizePropTypes} from '../../../components/withLocalize'; +import Form from '../../../components/Form'; const propTypes = { /** The children to show inside the layout */ @@ -54,9 +55,7 @@ const SignInPageLayoutWide = props => ( {props.welcomeText} )} - - {props.children} - +
{props.children}
diff --git a/src/styles/styles.js b/src/styles/styles.js index bab7359d1532..0b618043c38b 100644 --- a/src/styles/styles.js +++ b/src/styles/styles.js @@ -427,6 +427,13 @@ const styles = { width: variables.componentSizeNormal, }, + visuallyHidden: { + ...visibility('hidden'), + overflow: 'hidden', + width: 0, + height: 0, + }, + loadingVBAAnimation: { width: 160, height: 160,