Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

focus logic update for multiline fields #27702

Merged
merged 20 commits into from
Sep 22, 2023
Merged
23 changes: 23 additions & 0 deletions src/libs/UpdateMultilineInputRange/index.ios.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/**
* Place the cursor at the end of the value (if there is a value in the input).
*
* When a multiline input contains a text value that goes beyond the scroll height, the cursor will be placed
* at the end of the text value, and automatically scroll the input field to this position after the field gains
* focus. This provides a better user experience in cases where the text in the field has to be edited. The auto-
* scroll behaviour works on all platforms except iOS native.
* See https://github.com/Expensify/App/issues/20836 for more details.
*
* @param {Object} input the input element
*/
export default function updateMultilineInputRange(input) {
if (!input) {
return;
}

/*
* Adding this iOS specific patch because of the scroll issue in native iOS
* Issue: does not scroll multiline input when text exceeds the maximum number of lines
* For more details: https://github.com/Expensify/App/pull/27702#issuecomment-1728651132
*/
input.focus();
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/**
* Focus a multiline text input and place the cursor at the end of the value (if there is a value in the input).
* Place the cursor at the end of the value (if there is a value in the input).
*
* When a multiline input contains a text value that goes beyond the scroll height, the cursor will be placed
* at the end of the text value, and automatically scroll the input field to this position after the field gains
Expand All @@ -9,12 +9,11 @@
*
* @param {Object} input the input element
*/
export default function focusAndUpdateMultilineInputRange(input) {
export default function updateMultilineInputRange(input) {
if (!input) {
return;
}

input.focus();
if (input.value && input.setSelectionRange) {
const length = input.value.length;
input.setSelectionRange(length, length);
Expand Down
31 changes: 28 additions & 3 deletions src/pages/EditRequestDescriptionPage.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React, {useRef} from 'react';
import React, {useRef, useCallback} from 'react';
import {View} from 'react-native';
import PropTypes from 'prop-types';
import {useFocusEffect} from '@react-navigation/native';
import TextInput from '../components/TextInput';
import ScreenWrapper from '../components/ScreenWrapper';
import HeaderWithBackButton from '../components/HeaderWithBackButton';
Expand All @@ -10,6 +11,7 @@ import styles from '../styles/styles';
import CONST from '../CONST';
import useLocalize from '../hooks/useLocalize';
import * as Browser from '../libs/Browser';
import updateMultilineInputRange from '../libs/UpdateMultilineInputRange';

const propTypes = {
/** Transaction default description value */
Expand All @@ -22,11 +24,28 @@ const propTypes = {
function EditRequestDescriptionPage({defaultDescription, onSubmit}) {
const {translate} = useLocalize();
const descriptionInputRef = useRef(null);
const focusTimeoutRef = useRef(null);

useFocusEffect(
useCallback(() => {
focusTimeoutRef.current = setTimeout(() => {
if (descriptionInputRef.current) {
descriptionInputRef.current.focus();
}
return () => {
if (!focusTimeoutRef.current) {
return;
}
clearTimeout(focusTimeoutRef.current);
};
}, CONST.ANIMATED_TRANSITION);
}, []),
);

return (
<ScreenWrapper
includeSafeAreaPaddingBottom={false}
shouldEnableMaxHeight
onEntryTransitionEnd={() => descriptionInputRef.current && descriptionInputRef.current.focus()}
testID={EditRequestDescriptionPage.displayName}
>
<HeaderWithBackButton title={translate('common.description')} />
Expand All @@ -46,7 +65,13 @@ function EditRequestDescriptionPage({defaultDescription, onSubmit}) {
label={translate('moneyRequestConfirmationList.whatsItFor')}
accessibilityLabel={translate('moneyRequestConfirmationList.whatsItFor')}
accessibilityRole={CONST.ACCESSIBILITY_ROLE.TEXT}
ref={(e) => (descriptionInputRef.current = e)}
ref={(el) => {
if (!el) {
return;
}
descriptionInputRef.current = el;
updateMultilineInputRange(descriptionInputRef.current);
}}
autoGrowHeight
containerStyles={[styles.autoGrowHeightMultilineInput]}
textAlignVertical="top"
Expand Down
31 changes: 27 additions & 4 deletions src/pages/PrivateNotes/PrivateNotesEditPage.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import React, {useState, useRef} from 'react';
import React, {useState, useRef, useCallback} from 'react';
import PropTypes from 'prop-types';
import {View, Keyboard} from 'react-native';
import {withOnyx} from 'react-native-onyx';
import {useFocusEffect} from '@react-navigation/native';
import lodashGet from 'lodash/get';
import Str from 'expensify-common/lib/str';
import ExpensiMark from 'expensify-common/lib/ExpensiMark';
Expand All @@ -23,7 +24,7 @@ import personalDetailsPropType from '../personalDetailsPropType';
import * as Report from '../../libs/actions/Report';
import useLocalize from '../../hooks/useLocalize';
import OfflineWithFeedback from '../../components/OfflineWithFeedback';
import focusAndUpdateMultilineInputRange from '../../libs/focusAndUpdateMultilineInputRange';
import updateMultilineInputRange from '../../libs/UpdateMultilineInputRange';
import ROUTES from '../../ROUTES';

const propTypes = {
Expand Down Expand Up @@ -66,6 +67,23 @@ function PrivateNotesEditPage({route, personalDetailsList, session, report}) {

// To focus on the input field when the page loads
const privateNotesInput = useRef(null);
const focusTimeoutRef = useRef(null);

useFocusEffect(
useCallback(() => {
focusTimeoutRef.current = setTimeout(() => {
if (privateNotesInput.current) {
privateNotesInput.current.focus();
}
return () => {
if (!focusTimeoutRef.current) {
return;
}
clearTimeout(focusTimeoutRef.current);
};
}, CONST.ANIMATED_TRANSITION);
}, []),
);

const savePrivateNote = () => {
const editedNote = parser.replace(privateNote);
Expand All @@ -79,7 +97,6 @@ function PrivateNotesEditPage({route, personalDetailsList, session, report}) {
return (
<ScreenWrapper
includeSafeAreaPaddingBottom={false}
onEntryTransitionEnd={() => focusAndUpdateMultilineInputRange(privateNotesInput.current)}
testID={PrivateNotesEditPage.displayName}
>
<FullPageNotFoundView
Expand Down Expand Up @@ -128,7 +145,13 @@ function PrivateNotesEditPage({route, personalDetailsList, session, report}) {
defaultValue={privateNote}
value={privateNote}
onChangeText={(text) => setPrivateNote(text)}
ref={(el) => (privateNotesInput.current = el)}
ref={(el) => {
if (!el) {
return;
}
privateNotesInput.current = el;
updateMultilineInputRange(privateNotesInput.current);
}}
/>
</OfflineWithFeedback>
</Form>
Expand Down
104 changes: 54 additions & 50 deletions src/pages/ReportWelcomeMessagePage.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import PropTypes from 'prop-types';
import {withOnyx} from 'react-native-onyx';
import {View} from 'react-native';
import ExpensiMark from 'expensify-common/lib/ExpensiMark';
import {useFocusEffect} from '@react-navigation/native';
import compose from '../libs/compose';
import withLocalize, {withLocalizePropTypes} from '../components/withLocalize';
import ScreenWrapper from '../components/ScreenWrapper';
Expand All @@ -19,7 +20,7 @@ import FullPageNotFoundView from '../components/BlockingViews/FullPageNotFoundVi
import Form from '../components/Form';
import * as PolicyUtils from '../libs/PolicyUtils';
import {policyPropTypes, policyDefaultProps} from './workspace/withPolicy';
import focusAndUpdateMultilineInputRange from '../libs/focusAndUpdateMultilineInputRange';
import updateMultilineInputRange from '../libs/UpdateMultilineInputRange';

const propTypes = {
...withLocalizePropTypes,
Expand All @@ -45,6 +46,7 @@ function ReportWelcomeMessagePage(props) {
const parser = new ExpensiMark();
const [welcomeMessage, setWelcomeMessage] = useState(parser.htmlToMarkdown(props.report.welcomeMessage));
const welcomeMessageInputRef = useRef(null);
const focusTimeoutRef = useRef(null);

const handleWelcomeMessageChange = useCallback((value) => {
setWelcomeMessage(value);
Expand All @@ -54,56 +56,58 @@ function ReportWelcomeMessagePage(props) {
Report.updateWelcomeMessage(props.report.reportID, props.report.welcomeMessage, welcomeMessage.trim());
}, [props.report.reportID, props.report.welcomeMessage, welcomeMessage]);

return (
<ScreenWrapper
onEntryTransitionEnd={() => {
if (!welcomeMessageInputRef.current) {
return;
useFocusEffect(
useCallback(() => {
focusTimeoutRef.current = setTimeout(() => {
if (welcomeMessageInputRef.current) {
welcomeMessageInputRef.current.focus();
}
focusAndUpdateMultilineInputRange(welcomeMessageInputRef.current);
}}
testID={ReportWelcomeMessagePage.displayName}
>
{({didScreenTransitionEnd}) => (
<FullPageNotFoundView shouldShow={!PolicyUtils.isPolicyAdmin(props.policy)}>
<HeaderWithBackButton title={props.translate('welcomeMessagePage.welcomeMessage')} />
<Form
style={[styles.flexGrow1, styles.ph5]}
formID={ONYXKEYS.FORMS.WELCOME_MESSAGE_FORM}
onSubmit={submitForm}
submitButtonText={props.translate('common.save')}
enabledWhenOffline
>
<Text style={[styles.mb5]}>{props.translate('welcomeMessagePage.explainerText')}</Text>
<View style={[styles.mb6]}>
<TextInput
inputID="welcomeMessage"
label={props.translate('welcomeMessagePage.welcomeMessage')}
accessibilityLabel={props.translate('welcomeMessagePage.welcomeMessage')}
accessibilityRole={CONST.ACCESSIBILITY_ROLE.TEXT}
autoGrowHeight
maxLength={CONST.MAX_COMMENT_LENGTH}
ref={(el) => {
// Before updating the DOM, React sets the affected ref.current values to null. After updating the DOM, React immediately sets them to the corresponding DOM nodes
// to avoid focus multiple time, we should early return if el is null.
if (!el) {
return;
}
if (!welcomeMessageInputRef.current && didScreenTransitionEnd) {
focusAndUpdateMultilineInputRange(el);
}
welcomeMessageInputRef.current = el;
}}
value={welcomeMessage}
onChangeText={handleWelcomeMessageChange}
autoCapitalize="none"
textAlignVertical="top"
containerStyles={[styles.autoGrowHeightMultilineInput]}
/>
</View>
</Form>
</FullPageNotFoundView>
)}
return () => {
if (!focusTimeoutRef.current) {
return;
}
clearTimeout(focusTimeoutRef.current);
};
}, CONST.ANIMATED_TRANSITION);
}, []),
);

return (
<ScreenWrapper testID={ReportWelcomeMessagePage.displayName}>
<FullPageNotFoundView shouldShow={!PolicyUtils.isPolicyAdmin(props.policy)}>
<HeaderWithBackButton title={props.translate('welcomeMessagePage.welcomeMessage')} />
<Form
style={[styles.flexGrow1, styles.ph5]}
formID={ONYXKEYS.FORMS.WELCOME_MESSAGE_FORM}
onSubmit={submitForm}
submitButtonText={props.translate('common.save')}
enabledWhenOffline
>
<Text style={[styles.mb5]}>{props.translate('welcomeMessagePage.explainerText')}</Text>
<View style={[styles.mb6]}>
<TextInput
inputID="welcomeMessage"
label={props.translate('welcomeMessagePage.welcomeMessage')}
accessibilityLabel={props.translate('welcomeMessagePage.welcomeMessage')}
accessibilityRole={CONST.ACCESSIBILITY_ROLE.TEXT}
autoGrowHeight
maxLength={CONST.MAX_COMMENT_LENGTH}
ref={(el) => {
if (!el) {
return;
}
welcomeMessageInputRef.current = el;
updateMultilineInputRange(welcomeMessageInputRef.current);
}}
value={welcomeMessage}
onChangeText={handleWelcomeMessageChange}
autoCapitalize="none"
textAlignVertical="top"
containerStyles={[styles.autoGrowHeightMultilineInput]}
/>
</View>
</Form>
</FullPageNotFoundView>
</ScreenWrapper>
);
}
Expand Down
Loading
Loading