Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/components/MoneyRequestConfirmationFields/Provider.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React from 'react';
import type {ReactNode} from 'react';
import type {MeasurableInput} from '@components/SelectionList/SelectionListWithSections/types';
import type {IOUAction, IOUType} from '@src/CONST';
import type CONST from '@src/CONST';
import ConfirmationFieldsContext from './context';
Expand Down Expand Up @@ -59,6 +60,9 @@ type ProviderProps = {
/** Whether the active transaction is a GPS distance request */
isGPSDistanceRequest?: boolean;

/** Scrolls the surface so an inline field's input is not hidden behind the keyboard when focused (new manual expense flow) */
scrollFocusedInputIntoView?: (input: MeasurableInput) => void;

/** Block components rendered inside the Provider */
children: ReactNode;
};
Expand All @@ -82,6 +86,7 @@ function Provider({
isManualDistanceRequest = false,
isOdometerDistanceRequest = false,
isGPSDistanceRequest = false,
scrollFocusedInputIntoView,
children,
}: ProviderProps) {
const value = {
Expand All @@ -103,6 +108,7 @@ function Provider({
isManualDistanceRequest,
isOdometerDistanceRequest,
isGPSDistanceRequest,
scrollFocusedInputIntoView,
};
return <ConfirmationFieldsContext.Provider value={value}>{children}</ConfirmationFieldsContext.Provider>;
}
Expand Down
4 changes: 4 additions & 0 deletions src/components/MoneyRequestConfirmationFields/context.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {createContext, useContext} from 'react';
import type {MeasurableInput} from '@components/SelectionList/SelectionListWithSections/types';
import type {IOUAction, IOUType} from '@src/CONST';
import type CONST from '@src/CONST';

Expand Down Expand Up @@ -31,6 +32,9 @@ type ConfirmationFieldsContextValue = {
isManualDistanceRequest: boolean;
isOdometerDistanceRequest: boolean;
isGPSDistanceRequest: boolean;

/** Scrolls the surface so an inline field's input is not hidden behind the keyboard when focused (new manual expense flow). */
scrollFocusedInputIntoView?: (input: MeasurableInput) => void;
};

const ConfirmationFieldsContext = createContext<ConfirmationFieldsContextValue | null>(null);
Expand Down
12 changes: 11 additions & 1 deletion src/components/MoneyRequestConfirmationList.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {useIsFocused} from '@react-navigation/native';
import React, {useCallback, useEffect, useMemo, useState} from 'react';
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {View} from 'react-native';
import type {OnyxEntry} from 'react-native-onyx';
import useAttendees from '@hooks/useAttendees';
Expand Down Expand Up @@ -59,6 +59,7 @@ import TaxController from './MoneyRequestConfirmationList/TaxController';
import MoneyRequestConfirmationListFooter from './MoneyRequestConfirmationListFooter';
import BareUserListItem from './SelectionList/ListItem/BareUserListItem';
import SelectionListWithSections from './SelectionList/SelectionListWithSections';
import type {MeasurableInput, SelectionListWithSectionsHandle} from './SelectionList/SelectionListWithSections/types';

type MoneyRequestConfirmationListProps = {
/** Callback to inform parent modal of success */
Expand Down Expand Up @@ -234,6 +235,13 @@ function MoneyRequestConfirmationList({
const styles = useThemeStyles();
const currentUserPersonalDetails = useCurrentUserPersonalDetails();
const {isRestrictedToPreferredPolicy} = usePreferredPolicy();
const listRef = useRef<SelectionListWithSectionsHandle>(null);

// In the new manual expense flow the inline fields live in the list footer, so they can be hidden behind the keyboard.
// We let those fields ask the list to scroll them into view when focused.
const scrollFocusedInputIntoView = useCallback((input: MeasurableInput) => {
listRef.current?.scrollInputIntoView(input);
}, []);

const isDistanceRequest = isDistanceRequestUtil(transaction);
const isManualDistanceRequest = isManualDistanceRequestUtil(transaction);
Expand Down Expand Up @@ -555,6 +563,7 @@ function MoneyRequestConfirmationList({
}}
compactControls={{showMoreFields, setShowMoreFields}}
onSubmitForm={confirm}
scrollFocusedInputIntoView={scrollFocusedInputIntoView}
/>
</View>
);
Expand Down Expand Up @@ -624,6 +633,7 @@ function MoneyRequestConfirmationList({
/>
<MouseProvider>
<SelectionListWithSections<MoneyRequestConfirmationListItem>
ref={listRef}
sections={sections}
ListItem={BareUserListItem}
onSelectRow={navigateToParticipantPage}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React from 'react';
import React, {useRef} from 'react';
import {View} from 'react-native';
import type {OnyxEntry} from 'react-native-onyx';
import MentionReportContext from '@components/HTMLEngineProvider/HTMLRenderers/MentionReportRenderer/MentionReportContext';
Expand Down Expand Up @@ -49,9 +49,12 @@ function DescriptionField({
policy,
onSubmitForm,
}: DescriptionFieldProps) {
const {isEditingSplitBill} = useConfirmationFields();
const {isEditingSplitBill, scrollFocusedInputIntoView} = useConfirmationFields();
const styles = useThemeStyles();
const {translate} = useLocalize();
// Ref on the field's outer container (the bordered box), so scrolling brings the whole field — including its
// top border and label — into view rather than just the inner text area.
const fieldContainerRef = useRef<View>(null);

const [splitDraftTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`);

Expand Down Expand Up @@ -100,11 +103,15 @@ function DescriptionField({
<ShowContextMenuActionsContext.Provider value={contextMenuActionsValue}>
<MentionReportContext.Provider value={mentionReportContextValue}>
{isNewManualExpenseFlowEnabled && !isReadOnly ? (
<View style={[styles.mh4, styles.mv2]}>
<View
ref={fieldContainerRef}
style={[styles.mh4, styles.mv2]}
>
<TextInput
value={iouComment ?? ''}
readOnly={didConfirm}
onChangeText={handleDescriptionInputChange}
onFocus={() => scrollFocusedInputIntoView?.(fieldContainerRef.current)}
submitBehavior="blurAndSubmit"
onSubmitEditing={onSubmitForm}
label={translate('common.description')}
Expand Down
6 changes: 6 additions & 0 deletions src/components/MoneyRequestConfirmationListFooter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import DistanceMapSection from './MoneyRequestConfirmationListFooter/sections/Di
import InvoiceSenderSection from './MoneyRequestConfirmationListFooter/sections/InvoiceSenderSection';
import PerDiemSection from './MoneyRequestConfirmationListFooter/sections/PerDiemSection';
import ReceiptSection from './MoneyRequestConfirmationListFooter/sections/ReceiptSection';
import type {MeasurableInput} from './SelectionList/SelectionListWithSections/types';

const noopSetShowMoreFields = () => {};

Expand Down Expand Up @@ -108,6 +109,9 @@ type MoneyRequestConfirmationListFooterProps = {

/** Triggers submit from inline inputs */
onSubmitForm?: () => void;

/** Scrolls the surface so an inline field's input is not hidden behind the keyboard when focused (new manual expense flow) */
scrollFocusedInputIntoView?: (input: MeasurableInput) => void;
};

function MoneyRequestConfirmationListFooter({
Expand Down Expand Up @@ -137,6 +141,7 @@ function MoneyRequestConfirmationListFooter({
receiptOptions,
compactControls,
onSubmitForm,
scrollFocusedInputIntoView,
}: MoneyRequestConfirmationListFooterProps) {
const styles = useThemeStyles();
const isInLandscapeMode = useIsInLandscapeMode();
Expand Down Expand Up @@ -167,6 +172,7 @@ function MoneyRequestConfirmationListFooter({
isManualDistanceRequest={distanceFlags.isManualDistanceRequest}
isOdometerDistanceRequest={distanceFlags.isOdometerDistanceRequest}
isGPSDistanceRequest={distanceFlags.isGPSDistanceRequest}
scrollFocusedInputIntoView={scrollFocusedInputIntoView}
>
<View style={isCompactMode ? styles.flex1 : undefined}>
<View>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import Footer from '@components/SelectionList/components/Footer';
import SelectionListEmptyState from '@components/SelectionList/components/SelectionListEmptyState';
import TextInput from '@components/SelectionList/components/TextInput';
import useFlattenedSections, {isItemSelected, shouldTreatItemAsDisabled} from '@components/SelectionList/hooks/useFlattenedSections';
import useScrollToFocusedInput from '@components/SelectionList/hooks/useScrollToFocusedInput';
import useSearchFocusSync from '@components/SelectionList/hooks/useSearchFocusSync';
import useSelectedItemFocusSync from '@components/SelectionList/hooks/useSelectedItemFocusSync';
import useSelectionListKeyboardFocus from '@components/SelectionList/hooks/useSelectionListKeyboardFocus';
Expand Down Expand Up @@ -90,6 +91,7 @@ function BaseSelectionListWithSections<TItem extends ListItem>({
const {flattenedData, disabledIndexes, itemsCount, selectedItems, initialFocusedIndex, firstFocusableIndex} = useFlattenedSections(sections, initiallyFocusedItemKey);
const listRef = useRef<FlashListRef<FlattenedItem<TItem>> | null>(null);
const {scrollToIndex, debouncedScrollToIndex} = useSelectionListScroll(listRef, flattenedData);
const {containerRef, trackScrollOffset, scrollInputIntoView} = useScrollToFocusedInput(listRef, isKeyboardShown);

const {focusedIndex, setFocusedIndex, setFocusedIndexFromRowFocus, setFocusedIndexWithoutScrollOnChange, suppressNextFocusScroll, isKeyboardNavigating, setHasKeyBeenPressed} =
useSelectionListKeyboardFocus({
Expand Down Expand Up @@ -180,8 +182,9 @@ function BaseSelectionListWithSections<TItem extends ListItem>({
updateAndScrollToFocusedIndex,
updateExternalTextInputFocus,
getFocusedOption: getFocusedItem,
scrollInputIntoView,
}),
[focusTextInput, scrollToIndex, clearInputAfterSelect, updateAndScrollToFocusedIndex, updateExternalTextInputFocus, getFocusedItem],
[focusTextInput, scrollToIndex, clearInputAfterSelect, updateAndScrollToFocusedIndex, updateExternalTextInputFocus, getFocusedItem, scrollInputIntoView],
);

const syncedSearchValue = searchValueForFocusSync ?? textInputOptions?.value;
Expand Down Expand Up @@ -301,6 +304,7 @@ function BaseSelectionListWithSections<TItem extends ListItem>({

return (
<View
ref={containerRef}
style={[styles.flex1, addBottomSafeAreaPadding && paddingBottomStyle, style?.containerStyle]}
onLayout={onLayout}
>
Expand All @@ -327,7 +331,8 @@ function BaseSelectionListWithSections<TItem extends ListItem>({
onEndReachedThreshold={onEndReachedThreshold}
onScrollBeginDrag={onScrollBeginDrag}
scrollEnabled={scrollEnabled}
onScroll={() => {
onScroll={(event) => {
trackScrollOffset(event);
onScroll?.();
triggerScrollEvent();
}}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,13 +57,18 @@ type SelectionListWithSectionsProps<TItem extends ListItem> = BaseSelectionListP
titleNumberOfLines?: number;
};

type MeasurableInput = unknown;

type SelectionListWithSectionsHandle<TItem extends ListItem = ListItem> = {
focusTextInput: () => void;
scrollToIndex: (index: number) => void;
clearInputAfterSelect: () => void;
updateAndScrollToFocusedIndex: (index: number, shouldScroll?: boolean) => void;
updateExternalTextInputFocus: (isTextInputFocused: boolean) => void;
getFocusedOption: () => TItem | undefined;

/** Scrolls the list so an input rendered inside `listFooterContent` is not hidden behind the keyboard. */
scrollInputIntoView: (input: MeasurableInput) => void;
};

type SectionHeader = {
Expand All @@ -83,4 +88,4 @@ type SectionListItem<TItem extends ListItem> = TItem & {

type FlattenedItem<TItem extends ListItem> = SectionListItem<TItem> | SectionHeader;

export type {Section, ListItem, SectionListItem, SelectionListWithSectionsProps, SelectionListWithSectionsHandle, FlattenedItem};
export type {Section, ListItem, SectionListItem, SelectionListWithSectionsProps, SelectionListWithSectionsHandle, FlattenedItem, MeasurableInput};
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import {useCallback, useEffect, useRef} from 'react';
import type {EmitterSubscription, NativeScrollEvent, NativeSyntheticEvent, View} from 'react-native';
import {KeyboardEvents} from 'react-native-keyboard-controller';
import type {MeasurableInput} from '@components/SelectionList/SelectionListWithSections/types';
import CONST from '@src/CONST';
import type {UseScrollToFocusedInput} from './types';

/** Extra space (px) left between the focused input and the top of the visible list area after scrolling. */
const EXTRA_SCROLL_PADDING = 16;

type MeasureInWindowCallback = (x: number, y: number, width: number, height: number) => void;

type MeasurableNode = {
measureInWindow: (callback: MeasureInWindowCallback) => void;
};

function isMeasurable(node: MeasurableInput): node is MeasurableNode {
return typeof node === 'object' && node !== null && 'measureInWindow' in node && typeof node.measureInWindow === 'function';
}

/**
* Scrolls a `FlashList` so that an input rendered inside its footer is not hidden behind the keyboard.
*
* `BaseSelectionList` scrolls *list items* into view via `scrollToIndex`, but footer inputs aren't list items,
* so we instead pull the focused input up toward the top of the list area once the keyboard is shown.
*/
const useScrollToFocusedInput: UseScrollToFocusedInput = (listRef, isKeyboardShown) => {
const containerRef = useRef<View | null>(null);
const scrollOffsetRef = useRef(0);
const isKeyboardShownRef = useRef(isKeyboardShown);
const keyboardListenerRef = useRef<EmitterSubscription | null>(null);
const scrollTimeoutRef = useRef<NodeJS.Timeout | null>(null);

useEffect(() => {
isKeyboardShownRef.current = isKeyboardShown;
}, [isKeyboardShown]);

const cleanup = useCallback(() => {
keyboardListenerRef.current?.remove();
keyboardListenerRef.current = null;
if (!scrollTimeoutRef.current) {
return;
}
clearTimeout(scrollTimeoutRef.current);
scrollTimeoutRef.current = null;
}, []);

useEffect(() => cleanup, [cleanup]);

const trackScrollOffset = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
scrollOffsetRef.current = event.nativeEvent.contentOffset.y;
}, []);

const scrollInputIntoView = useCallback(
(input: MeasurableInput) => {
if (!isMeasurable(input)) {
return;
}

const performScroll = () => {
const container = containerRef.current;
const list = listRef.current;
if (!container || !list) {
return;
}
// The list container grows to fit its content, so we can't treat its bottom as the visible area.
// Instead we use its top (just below the header) as a stable anchor and pull the focused input up
// toward it. The list viewport is bounded above any sticky footer (which itself sits above the
// keyboard), so anchoring to the top reliably brings the input into view, clamped by the available
// scroll range.
container.measureInWindow((containerX, containerY) => {
input.measureInWindow((inputX, inputY) => {
const target = containerY + EXTRA_SCROLL_PADDING;
const delta = inputY - target;
// The input is already at or above the target anchor, so there's nothing to do.
if (delta <= 0) {
return;
}
list.scrollToOffset({offset: scrollOffsetRef.current + delta, animated: true});
});
});
};

cleanup();

// The keyboard is already up (e.g. moving focus between fields), so the layout is settled — scroll right away.
if (isKeyboardShownRef.current) {
scrollTimeoutRef.current = setTimeout(performScroll, CONST.ANIMATION_IN_TIMING);
return;
}

// Otherwise wait for the keyboard to appear before measuring. We use react-native-keyboard-controller's
// `keyboardDidShow` (the same source as `withKeyboardState`) rather than RN's `Keyboard`, because RN's
// `keyboardDidShow` does not fire on Android 10 and below under `adjustResize`, which would leave the
// focused field hidden until the safety timeout below.
keyboardListenerRef.current = KeyboardEvents.addListener('keyboardDidShow', () => {
cleanup();
// Give the layout a moment to settle after the keyboard has fully appeared.
scrollTimeoutRef.current = setTimeout(performScroll, CONST.ANIMATION_IN_TIMING);
});

// Safety net in case the keyboard event never fires; long enough that the listener wins under normal conditions.
scrollTimeoutRef.current = setTimeout(() => {
cleanup();
performScroll();
}, CONST.MAX_TRANSITION_DURATION_MS);
},
[cleanup, listRef],
);

return {containerRef, trackScrollOffset, scrollInputIntoView};
};

export default useScrollToFocusedInput;
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import {useRef} from 'react';
import type {View} from 'react-native';
import type {UseScrollToFocusedInput} from './types';

const noop = () => {};

/**
* No-op on web: browsers automatically scroll a focused input into view, so there's nothing to do here.
* `containerRef` is still returned so the list can attach it harmlessly.
*/
const useScrollToFocusedInput: UseScrollToFocusedInput = () => {
const containerRef = useRef<View | null>(null);
return {containerRef, trackScrollOffset: noop, scrollInputIntoView: noop};
};

export default useScrollToFocusedInput;
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type {FlashListRef} from '@shopify/flash-list';
import type {RefObject} from 'react';
import type {NativeScrollEvent, NativeSyntheticEvent, View} from 'react-native';
import type {MeasurableInput} from '@components/SelectionList/SelectionListWithSections/types';

type UseScrollToFocusedInputResult = {
/** Attach to the list's outer container; its top is used as a stable anchor to pull focused inputs up to. */
containerRef: RefObject<View | null>;

/** Wire into the list's `onScroll` so we always know the current content offset. */
trackScrollOffset: (event: NativeSyntheticEvent<NativeScrollEvent>) => void;

/** Scrolls the list so the given input is visible above the keyboard. Safe to call from an input's `onFocus`. */
scrollInputIntoView: (input: MeasurableInput) => void;
};

type UseScrollToFocusedInput = (listRef: RefObject<Pick<FlashListRef<unknown>, 'scrollToOffset'> | null>, isKeyboardShown: boolean) => UseScrollToFocusedInputResult;

// eslint-disable-next-line import/prefer-default-export
export type {UseScrollToFocusedInput};
Loading