From 4fee957c779818bd25a7d7789ba634ac2ce6e260 Mon Sep 17 00:00:00 2001 From: GCyganek Date: Mon, 31 Aug 2026 16:14:02 +0200 Subject: [PATCH 01/14] Add mentions and emojis suggestions to Home prompt box; fix RHP suggestions --- config/eslint/eslint.seatbelt.tsv | 1 + .../TransparentOverlay/TransparentOverlay.tsx | 9 +- .../AutoCompleteSuggestionsPortal/index.tsx | 11 +- .../home/ForYouSection/ConciergePromptBox.tsx | 51 ++- src/pages/home/HomePage.tsx | 2 + .../ReportActionCompose/ComposerInput.tsx | 5 - .../ComposerWithSuggestions.tsx | 96 +----- .../useComposerSuggestions.ts | 115 +++++++ .../report/ReportActionItemMessageEdit.tsx | 94 +---- src/styles/variables.ts | 1 + tests/ui/ConciergePromptBoxTest.tsx | 324 +++++++++++++++++- tests/unit/pages/HomePage.test.tsx | 36 +- 12 files changed, 560 insertions(+), 185 deletions(-) create mode 100644 src/pages/inbox/report/ReportActionCompose/useComposerSuggestions.ts diff --git a/config/eslint/eslint.seatbelt.tsv b/config/eslint/eslint.seatbelt.tsv index cc4071f72683..eab4d5a08911 100644 --- a/config/eslint/eslint.seatbelt.tsv +++ b/config/eslint/eslint.seatbelt.tsv @@ -973,6 +973,7 @@ "../../src/pages/ValidateLoginPage/index.web.tsx" "react-hooks/set-state-in-effect" 1 "../../src/pages/domain/Groups/PreferredWorkspaceToggle.tsx" "@typescript-eslint/no-deprecated/ConfirmModal" 1 "../../src/pages/domain/Saml/SamlLoginSectionContent.tsx" "@typescript-eslint/no-deprecated/ConfirmModal" 1 +"../../src/pages/home/ForYouSection/ConciergePromptBox.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/home/GettingStartedSection/hooks/useGettingStartedItems.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/home/GettingStartedSection/hooks/useGettingStartedItems.ts" "no-restricted-imports" 1 "../../src/pages/home/TimeSensitiveSection/items/FixCompanyCardConnection.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 diff --git a/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/TransparentOverlay/TransparentOverlay.tsx b/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/TransparentOverlay/TransparentOverlay.tsx index 6c6fb37e4fbe..9a90271fe6f3 100644 --- a/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/TransparentOverlay/TransparentOverlay.tsx +++ b/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/TransparentOverlay/TransparentOverlay.tsx @@ -9,18 +9,21 @@ import CONST from '@src/CONST'; import htmlDivElementRef from '@src/types/utils/htmlDivElementRef'; import viewRef from '@src/types/utils/viewRef'; -import type {PointerEvent} from 'react-native'; +import type {PointerEvent, StyleProp, ViewStyle} from 'react-native'; import React, {useCallback, useMemo, useRef} from 'react'; import {View} from 'react-native'; type TransparentOverlayProps = { onPress: () => void; + + /** Additional styles for the overlay */ + style?: StyleProp; }; type OnPressHandler = PressableProps['onPress']; -function TransparentOverlay({onPress: onPressProp}: TransparentOverlayProps) { +function TransparentOverlay({onPress: onPressProp, style}: TransparentOverlayProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); const dropZone = useRef(null); @@ -54,7 +57,7 @@ function TransparentOverlay({onPress: onPressProp}: TransparentOverlayProps) { return ( ({ bodyElement && ReactDOM.createPortal( <> - - {componentToRender} + + {componentToRender} , bodyElement, ) diff --git a/src/pages/home/ForYouSection/ConciergePromptBox.tsx b/src/pages/home/ForYouSection/ConciergePromptBox.tsx index 9ba2b9648dcb..f62c0436955d 100644 --- a/src/pages/home/ForYouSection/ConciergePromptBox.tsx +++ b/src/pages/home/ForYouSection/ConciergePromptBox.tsx @@ -1,6 +1,6 @@ import AttachmentPicker from '@components/AttachmentPicker'; import Composer from '@components/Composer'; -import type {ComposerRef} from '@components/Composer/types'; +import type {ComposerRef, TextSelection} from '@components/Composer/types'; import ExceededCommentLength from '@components/ExceededCommentLength'; import Icon from '@components/Icon'; import PopoverMenu from '@components/PopoverMenu'; @@ -28,6 +28,8 @@ import getButtonState from '@libs/getButtonState'; import interceptAnonymousUser from '@libs/interceptAnonymousUser'; import SubmitDraftButton from '@pages/inbox/report/ReportActionCompose/SubmitDraftButton'; +import Suggestions from '@pages/inbox/report/ReportActionCompose/Suggestions'; +import useComposerSuggestions from '@pages/inbox/report/ReportActionCompose/useComposerSuggestions'; import useDebouncedCommentMaxLengthValidation from '@pages/inbox/report/ReportActionCompose/useDebouncedCommentMaxLengthValidation'; import useDebouncedSaveDraft from '@pages/inbox/report/useDebouncedSaveDraft'; @@ -84,7 +86,7 @@ function ConciergePromptBox({isMenuVisible, setIsMenuVisible}: ConciergePromptBo // Composer is a controlled input: the caret position must be tracked and fed back in (with // shouldCalculateCaretPosition), otherwise every value update re-renders it with the caret at the start. - const [selection, setSelection] = useState({start: value.length, end: value.length}); + const [selection, setSelection] = useState({start: value.length, end: value.length}); const [lastSyncedDraft, setLastSyncedDraft] = useState(draft); const {saveDraft: debouncedSaveDraft, cancelSaveDraft} = useDebouncedSaveDraft( @@ -112,14 +114,36 @@ function ConciergePromptBox({isMenuVisible, setIsMenuVisible}: ConciergePromptBo const actionButtonRef = useRef(null); const animatedRef = useAnimatedRef(); + const containerRef = useRef(null); + // The native Composer only forwards its underlying input to a callback ref, so an object ref would never be populated. const composerRef = useRef(null); + const {suggestionsRef, measureParentContainerAndReportCursor, hideSuggestionMenu, onSaveScrollAndHideSuggestionMenu, raiseIsScrollLayoutTriggered} = useComposerSuggestions({ + composerRef, + selection, + measureParentContainer: (callback) => containerRef.current?.measureInWindow(callback), + }); + const setComposerRef = (element: ComposerRef) => { animatedRef(element); composerRef.current = element; }; + // Shared by typing and by inserting a mention, so a mention takes the same validation and draft-save path as typed text. + const updateComment = (text: string) => { + setValue(text); + debouncedCommentMaxLengthValidation(text); + debouncedSaveDraft(text); + }; + + useEffect(() => { + if (isFocused) { + return; + } + hideSuggestionMenu(); + }, [isFocused, hideSuggestionMenu]); + const clearInput = () => { setValue(''); setSelection({start: 0, end: 0}); @@ -182,6 +206,11 @@ function ConciergePromptBox({isMenuVisible, setIsMenuVisible}: ConciergePromptBo if (canSkipTriggerHotkeys(shouldUseNarrowLayout, isKeyboardShown)) { return; } + + if (suggestionsRef.current?.triggerHotkeyActions(event as unknown as KeyboardEvent)) { + return; + } + const {nativeEvent} = event; const hasShiftModifier = 'shiftKey' in nativeEvent && !!nativeEvent.shiftKey; if (nativeEvent.key !== CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey || hasShiftModifier) { @@ -199,6 +228,8 @@ function ConciergePromptBox({isMenuVisible, setIsMenuVisible}: ConciergePromptBo { - setValue(text); - debouncedCommentMaxLengthValidation(text); - debouncedSaveDraft(text); + raiseIsScrollLayoutTriggered(); + updateComment(text); }} + onScroll={onSaveScrollAndHideSuggestionMenu} selection={selection} onSelectionChange={(event) => setSelection(event.nativeEvent.selection)} shouldCalculateCaretPosition @@ -328,6 +359,16 @@ function ConciergePromptBox({isMenuVisible, setIsMenuVisible}: ConciergePromptBo + {/* Mirror ComposerSendButton: the justifyContentEnd wrapper stretches to the row height and anchors the send button to the bottom. */} + & /** Function to measure the parent container */ measureParentContainer: (callback: MeasureInWindowOnSuccessCallback) => void; - /** Whether the scroll is likely to trigger a layout */ - isScrollLikelyLayoutTriggered: RefObject; - - /** Function to raise the scroll is likely layout triggered */ - raiseIsScrollLikelyLayoutTriggered: () => void; - /** The ref to the suggestions */ suggestionsRef: React.RefObject; @@ -233,8 +214,6 @@ function ComposerWithSuggestions({ disabled, onEnterKeyPress, measureParentContainer = () => {}, - isScrollLikelyLayoutTriggered, - raiseIsScrollLikelyLayoutTriggered, onClear: onClearProp = () => {}, onLayout, @@ -260,9 +239,6 @@ function ComposerWithSuggestions({ const isFocused = useIsFocused(); const navigation = useNavigation(); const emojisPresentBefore = useRef([]); - const mobileInputScrollPosition = useRef(0); - const cursorPositionValue = useSharedValue({x: 0, y: 0}); - const tag = useSharedValue(-1); const isInSidePanel = useIsInSidePanel(); const {shouldUseNarrowLayout} = useResponsiveLayout(); @@ -327,6 +303,13 @@ function ComposerWithSuggestions({ const [selection, setSelection] = useState(() => currentEditMessageSelection ?? {start: initialText.length, end: initialText.length}); + const {measureParentContainerAndReportCursor, onSaveScrollAndHideSuggestionMenu, resetScrollPosition, raiseIsScrollLayoutTriggered} = useComposerSuggestions({ + composerRef, + selection, + measureParentContainer, + suggestionsRef, + }); + const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); const commentRef = useRef(initialText); @@ -502,7 +485,7 @@ function ComposerWithSuggestions({ */ const updateComment = useCallback( (commentValue: string, shouldDebounceSaveComment?: boolean) => { - raiseIsScrollLikelyLayoutTriggered(); + raiseIsScrollLayoutTriggered(); // previous text before change const prevText = lastTextRef.current; @@ -588,7 +571,7 @@ function ComposerWithSuggestions({ } }, [ - raiseIsScrollLikelyLayoutTriggered, + raiseIsScrollLayoutTriggered, selection.start, selection.end, findNewlyAddedChars, @@ -760,17 +743,6 @@ function ComposerWithSuggestions({ [setCurrentEditMessageSelection, suggestionsRef, currentEditMessageSelection, selection], ); - const hideSuggestionMenu = useCallback( - (e: TextInputScrollEvent) => { - mobileInputScrollPosition.current = e?.nativeEvent?.contentOffset?.y ?? 0; - if (!suggestionsRef.current || isScrollLikelyLayoutTriggered.current) { - return; - } - suggestionsRef.current.updateShouldShowSuggestionMenuToFalse(false); - }, - [suggestionsRef, isScrollLikelyLayoutTriggered], - ); - const setShouldBlockSuggestionCalcToFalse = useCallback(() => { if (!suggestionsRef.current) { return false; @@ -986,50 +958,12 @@ function ComposerWithSuggestions({ const onClear = useCallback( (textOnClear: string) => { - mobileInputScrollPosition.current = 0; + resetScrollPosition(); // Note: use the value when the clear happened, not the current value which might have changed already onClearProp(textOnClear); updateComment('', true); }, - [onClearProp, updateComment], - ); - - useEffect(() => { - // We use the tag to store the native ID of the text input. Later, we use it in onSelectionChange to pick up the proper text input data. - tag.set(findNodeHandle(composerRef.current) ?? -1); - }, [tag]); - - useFocusedInputHandler( - { - onSelectionChange: (event) => { - 'worklet'; - - if (event.target === tag.get()) { - cursorPositionValue.set({ - x: event.selection.end.x, - y: event.selection.end.y, - }); - } - }, - }, - [], - ); - const measureParentContainerAndReportCursor = useCallback( - (callback: MeasureParentContainerAndCursorCallback) => { - const {scrollValue} = getScrollPosition({mobileInputScrollPosition, textInputRef: composerRef}); - const {x: xPosition, y: yPosition} = getCursorPosition({positionOnMobile: cursorPositionValue.get(), positionOnWeb: selection}); - measureParentContainer((x, y, width, height) => { - callback({ - x, - y, - width, - height, - scrollValue, - cursorCoordinates: {x: xPosition, y: yPosition}, - }); - }); - }, - [measureParentContainer, cursorPositionValue, selection], + [onClearProp, updateComment, resetScrollPosition], ); const isTouchEndedRef = useRef(false); @@ -1128,7 +1062,7 @@ function ComposerWithSuggestions({ testID={CONST.COMPOSER.NATIVE_ID} shouldCalculateCaretPosition onLayout={onLayout} - onScroll={hideSuggestionMenu} + onScroll={onSaveScrollAndHideSuggestionMenu} shouldContainScroll={isMobileSafari()} isGroupPolicyReport={isGroupPolicyReport} forwardedFSClass={forwardedFSClass} diff --git a/src/pages/inbox/report/ReportActionCompose/useComposerSuggestions.ts b/src/pages/inbox/report/ReportActionCompose/useComposerSuggestions.ts new file mode 100644 index 000000000000..abbc268c87ed --- /dev/null +++ b/src/pages/inbox/report/ReportActionCompose/useComposerSuggestions.ts @@ -0,0 +1,115 @@ +import type {MeasureParentContainerAndCursorCallback} from '@components/AutoCompleteSuggestions/types'; +import type {ComposerRef, TextSelection} from '@components/Composer/types'; + +import useIsScrollLikelyLayoutTriggered from '@hooks/useIsScrollLikelyLayoutTriggered'; + +// eslint-disable-next-line no-restricted-imports +import findNodeHandle from '@src/utils/findNodeHandle'; + +import type {RefObject} from 'react'; +import type {MeasureInWindowOnSuccessCallback, TextInputScrollEvent} from 'react-native'; + +import {useEffect, useRef} from 'react'; +import {useFocusedInputHandler} from 'react-native-keyboard-controller'; +import {useSharedValue} from 'react-native-reanimated'; + +import type {SuggestionsRef} from './ComposerContext'; + +import getCursorPosition from './getCursorPosition'; +import getScrollPosition from './getScrollPosition'; + +type UseComposerSuggestionsParams = { + /** Ref to the composer input the suggestions are attached to */ + composerRef: RefObject; + + /** The current selection of the composer, used as the cursor position on web */ + selection: TextSelection; + + /** Measures the container the suggestion menu is positioned against */ + measureParentContainer: (callback: MeasureInWindowOnSuccessCallback) => void; + + /** Suggestions ref owned by a parent. When omitted, the hook owns the ref itself */ + suggestionsRef?: RefObject; +}; + +/** + * Wires up everything a composer needs to render + */ +function useComposerSuggestions({composerRef, selection, measureParentContainer, suggestionsRef: suggestionsRefProp}: UseComposerSuggestionsParams) { + const ownSuggestionsRef = useRef(null); + const suggestionsRef = suggestionsRefProp ?? ownSuggestionsRef; + + const {isScrollLayoutTriggered, raiseIsScrollLayoutTriggered} = useIsScrollLikelyLayoutTriggered(); + + const mobileInputScrollPosition = useRef(0); + const cursorPositionValue = useSharedValue({x: 0, y: 0}); + const tag = useSharedValue(-1); + + useEffect(() => { + // We use the tag to store the native ID of the text input. Later, we use it in onSelectionChange to pick up the proper text input data. + tag.set(findNodeHandle(composerRef.current) ?? -1); + }, [tag, composerRef]); + + useFocusedInputHandler( + { + onSelectionChange: (event) => { + 'worklet'; + + if (event.target === tag.get()) { + cursorPositionValue.set({ + x: event.selection.end.x, + y: event.selection.end.y, + }); + } + }, + }, + [], + ); + + const measureParentContainerAndReportCursor = (callback: MeasureParentContainerAndCursorCallback) => { + const {scrollValue} = getScrollPosition({mobileInputScrollPosition, textInputRef: composerRef}); + const {x: xPosition, y: yPosition} = getCursorPosition({positionOnMobile: cursorPositionValue.get(), positionOnWeb: selection}); + measureParentContainer((x, y, width, height) => { + callback({ + x, + y, + width, + height, + scrollValue, + cursorCoordinates: {x: xPosition, y: yPosition}, + }); + }); + }; + + const hideSuggestionMenu = () => { + suggestionsRef.current?.updateShouldShowSuggestionMenuToFalse(false); + }; + + const onSaveScrollAndHideSuggestionMenu = (event: TextInputScrollEvent) => { + // The offset is recorded even for a layout-triggered scroll, so the menu stays anchored to the real caret position. + mobileInputScrollPosition.current = event?.nativeEvent?.contentOffset?.y ?? 0; + + if (isScrollLayoutTriggered.current) { + return; + } + + hideSuggestionMenu(); + }; + + /** Forgets the tracked scroll offset, e.g. when the composer input is cleared and scrolled back to the top */ + const resetScrollPosition = () => { + mobileInputScrollPosition.current = 0; + }; + + return { + suggestionsRef, + measureParentContainerAndReportCursor, + hideSuggestionMenu, + onSaveScrollAndHideSuggestionMenu, + resetScrollPosition, + raiseIsScrollLayoutTriggered, + }; +} + +export default useComposerSuggestions; +export type {UseComposerSuggestionsParams}; diff --git a/src/pages/inbox/report/ReportActionItemMessageEdit.tsx b/src/pages/inbox/report/ReportActionItemMessageEdit.tsx index dff13702c3e0..4846090b469c 100644 --- a/src/pages/inbox/report/ReportActionItemMessageEdit.tsx +++ b/src/pages/inbox/report/ReportActionItemMessageEdit.tsx @@ -1,13 +1,11 @@ import type {Emoji} from '@assets/emojis/types'; -import type {MeasureParentContainerAndCursorCallback} from '@components/AutoCompleteSuggestions/types'; import Composer from '@components/Composer'; import type {ComposerRef, TextSelection} from '@components/Composer/types'; import EmojiPickerButton from '@components/EmojiPicker/EmojiPickerButton'; import ExceededCommentLength from '@components/ExceededCommentLength'; import {useBlockedFromConcierge} from '@components/OnyxListItemProvider'; -import useIsScrollLikelyLayoutTriggered from '@hooks/useIsScrollLikelyLayoutTriggered'; import useKeyboardState from '@hooks/useKeyboardState'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; @@ -37,24 +35,17 @@ import {isBlockedFromConcierge} from '@userActions/User'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type * as OnyxTypes from '@src/types/onyx'; -// eslint-disable-next-line no-restricted-imports -import findNodeHandle from '@src/utils/findNodeHandle'; -import type {MeasureInWindowOnSuccessCallback, TextInputKeyPressEvent, TextInputScrollEvent} from 'react-native'; +import type {TextInputKeyPressEvent} from 'react-native'; import React, {useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react'; import {View} from 'react-native'; -import {useFocusedInputHandler} from 'react-native-keyboard-controller'; -import {useSharedValue} from 'react-native-reanimated'; - -import type {SuggestionsRef} from './ReportActionCompose/ReportActionCompose'; import * as ReportActionContextMenu from './ContextMenu/ReportActionContextMenu'; -import getCursorPosition from './ReportActionCompose/getCursorPosition'; -import getScrollPosition from './ReportActionCompose/getScrollPosition'; import MessageEditCancelButton from './ReportActionCompose/MessageEditCancelButton'; import SubmitDraftButton from './ReportActionCompose/SubmitDraftButton'; import Suggestions from './ReportActionCompose/Suggestions'; +import useComposerSuggestions from './ReportActionCompose/useComposerSuggestions'; import useDebouncedCommentMaxLengthValidation from './ReportActionCompose/useDebouncedCommentMaxLengthValidation'; import useEditMessage from './ReportActionCompose/useEditMessage'; import {useReportActionActiveEdit, useReportActionActiveEditActions} from './ReportActionEditMessageContext'; @@ -105,10 +96,6 @@ function ReportActionItemMessageEdit({action, reportID, originalReportID, policy const {translate, preferredLocale} = useLocalize(); const {isKeyboardShown} = useKeyboardState(); const {shouldUseNarrowLayout} = useResponsiveLayout(); - const suggestionsRef = useRef(null); - const mobileInputScrollPosition = useRef(0); - const cursorPositionValue = useSharedValue({x: 0, y: 0}); - const tag = useSharedValue(-1); const emojisPresentBefore = useRef([]); const icons = useMemoizedLazyExpensifyIcons(['Checkmark']); @@ -144,8 +131,6 @@ function ReportActionItemMessageEdit({action, reportID, originalReportID, policy isEditing: true, }); - const {isScrollLayoutTriggered, raiseIsScrollLayoutTriggered} = useIsScrollLikelyLayoutTriggered(); - const [modal = DEFAULT_MODAL_VALUE] = useOnyx(ONYXKEYS.MODAL); const [onyxInputFocused = false] = useOnyx(ONYXKEYS.INPUT_FOCUSED); @@ -153,6 +138,12 @@ function ReportActionItemMessageEdit({action, reportID, originalReportID, policy const draftRef = useRef(draft); const emojiPickerSelectionRef = useRef(undefined); + const {suggestionsRef, measureParentContainerAndReportCursor, hideSuggestionMenu, onSaveScrollAndHideSuggestionMenu, raiseIsScrollLayoutTriggered} = useComposerSuggestions({ + composerRef, + selection, + measureParentContainer: (callback) => containerRef.current?.measureInWindow(callback), + }); + // Save the draft of the comment. This debounced so that we're not ceaselessly saving your edit. Saving the draft // allows one to navigate somewhere else and come back to the comment and still have it in edit mode. const {saveDraft, isSavePending: isDraftSavePending} = useDebouncedSaveDraft(saveReportActionDraft); @@ -285,24 +276,6 @@ function ReportActionItemMessageEdit({action, reportID, originalReportID, policy updateDraft(insertText(draft, selection, `${emoji} `)); }; - const hideSuggestionMenu = useCallback(() => { - if (!suggestionsRef.current) { - return; - } - suggestionsRef.current.updateShouldShowSuggestionMenuToFalse(false); - }, [suggestionsRef]); - const onSaveScrollAndHideSuggestionMenu = useCallback( - (e: TextInputScrollEvent) => { - if (isScrollLayoutTriggered.current) { - return; - } - mobileInputScrollPosition.current = e?.nativeEvent?.contentOffset?.y ?? 0; - - hideSuggestionMenu(); - }, - [isScrollLayoutTriggered, hideSuggestionMenu], - ); - /** * Key event handlers that short cut to saving/canceling. * @@ -333,56 +306,7 @@ function ReportActionItemMessageEdit({action, reportID, originalReportID, policy deleteDraft(); } }, - [shouldUseNarrowLayout, isKeyboardShown, hideSuggestionMenu, publishDraft, draft, deleteDraft], - ); - - const measureContainer = useCallback((callback: MeasureInWindowOnSuccessCallback) => { - if (!containerRef.current) { - return; - } - containerRef.current.measureInWindow(callback); - }, []); - - const measureParentContainerAndReportCursor = useCallback( - (callback: MeasureParentContainerAndCursorCallback) => { - const performMeasurement = () => { - const {scrollValue} = getScrollPosition({mobileInputScrollPosition, textInputRef: composerRef}); - const {x: xPosition, y: yPosition} = getCursorPosition({positionOnMobile: cursorPositionValue.get(), positionOnWeb: selection}); - measureContainer((x, y, width, height) => { - callback({ - x, - y, - width, - height, - scrollValue, - cursorCoordinates: {x: xPosition, y: yPosition}, - }); - }); - }; - - performMeasurement(); - }, - [cursorPositionValue, measureContainer, selection], - ); - - useEffect(() => { - // We use the tag to store the native ID of the text input. Later, we use it in onSelectionChange to pick up the proper text input data. - tag.set(findNodeHandle(composerRef.current) ?? -1); - }, [tag]); - useFocusedInputHandler( - { - onSelectionChange: (event) => { - 'worklet'; - - if (event.target === tag.get()) { - cursorPositionValue.set({ - x: event.selection.end.x, - y: event.selection.end.y, - }); - } - }, - }, - [], + [shouldUseNarrowLayout, isKeyboardShown, hideSuggestionMenu, publishDraft, draft, deleteDraft, suggestionsRef], ); useEffect(() => { diff --git a/src/styles/variables.ts b/src/styles/variables.ts index e9911c48fffd..92ed0d7ee1fe 100644 --- a/src/styles/variables.ts +++ b/src/styles/variables.ts @@ -284,6 +284,7 @@ export default { reportActionItemImagesMoreCornerTriangleWidth: 40, popoverZIndex: 10000, modalBaseZIndex: 9999, + autoCompleteSuggestionsZIndex: 9999, sidePanelZIndex: 9998, modalRightDockedZIndex: 9997, modalLowestZIndex: 9996, diff --git a/tests/ui/ConciergePromptBoxTest.tsx b/tests/ui/ConciergePromptBoxTest.tsx index 71e4088709e1..771af348e0d1 100644 --- a/tests/ui/ConciergePromptBoxTest.tsx +++ b/tests/ui/ConciergePromptBoxTest.tsx @@ -1,5 +1,9 @@ import {act, fireEvent, render, screen, waitFor} from '@testing-library/react-native'; +import type {Emoji} from '@assets/emojis/types'; + +import type {Mention} from '@components/MentionSuggestions'; +import {usePersonalDetails} from '@components/OnyxListItemProvider'; import useAskConcierge from '@components/Search/SearchRouter/useAskConcierge'; import useKeyboardState from '@hooks/useKeyboardState'; @@ -14,9 +18,14 @@ import {close} from '@userActions/Modal'; import {isAnonymousUser, signOutAndRedirectToSignIn} from '@userActions/Session'; import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {PersonalDetailsList} from '@src/types/onyx'; import type {FileObject} from '@src/types/utils/Attachment'; +import type {ViewProps} from 'react-native'; + import React, {useState} from 'react'; +import Onyx from 'react-native-onyx'; import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; @@ -26,6 +35,10 @@ const SHORT_PLACEHOLDER = 'homePage.conciergePrompt.inputPlaceholderMobile'; const ADD_ATTACHMENT = 'reportActionCompose.addAttachment'; const PLUS_BUTTON = 'accessibilityHints.openActionsMenu'; const SEND_BUTTON = 'common.send'; +const TEAMMATE = {accountID: 2, login: 'alex@expensify.com', displayName: 'Alex Adams'}; +const OTHER_TEAMMATE = {accountID: 3, login: 'blake@expensify.com', displayName: 'Blake Brown'}; + +const SCROLL_LAYOUT_TRIGGER_RESET_TIME = 500; const mockAskConcierge = jest.fn(); const mockAskConciergeWithAttachment = jest.fn(); @@ -34,8 +47,53 @@ const mockOpenPicker = jest.fn(); const pickerHandler: {onConfirm?: (files: FileObject | FileObject[]) => void} = {}; +type MentionSuggestionsProps = { + mentions: Mention[]; + prefix: string; + onSelect: (index: number) => void; +}; + +type EmojiSuggestionsProps = { + emojis: Emoji[]; + prefix: string; + onSelect: (index: number) => void; +}; + +const mockMentionSuggestionsSpy = jest.fn(); +const mockEmojiSuggestionsSpy = jest.fn(); + +let mockPersonalDetails: PersonalDetailsList = {}; + jest.mock('@components/Search/SearchRouter/useAskConcierge', () => jest.fn()); +jest.mock('@components/MentionSuggestions', () => { + const ReactLib = jest.requireActual('react'); + const module = { + default: (props: MentionSuggestionsProps) => { + mockMentionSuggestionsSpy(props); + return ReactLib.createElement('mock-mention-suggestions', {...props, testID: 'mention-suggestions'}); + }, + }; + Object.defineProperty(module, '__esModule', {value: true}); + return module; +}); + +jest.mock('@components/EmojiSuggestions', () => { + const ReactLib = jest.requireActual('react'); + const module = { + default: (props: EmojiSuggestionsProps) => { + mockEmojiSuggestionsSpy(props); + return ReactLib.createElement('mock-emoji-suggestions', {...props, testID: 'emoji-suggestions'}); + }, + }; + Object.defineProperty(module, '__esModule', {value: true}); + return module; +}); + +jest.mock('@components/OnyxListItemProvider', () => ({ + usePersonalDetails: jest.fn(), +})); + jest.mock('@pages/home/ForYouSection/useConciergeAttachmentPicker', () => ({ __esModule: true, default: (_reportID: string | undefined, onConfirm: (files: FileObject | FileObject[]) => void) => { @@ -75,6 +133,8 @@ jest.mock('@hooks/useLocalize', () => jest.fn(() => ({ translate: (key: string) => key, getLocalDateFromDatetime: () => new Date('2026-08-24T09:00:00'), + formatPhoneNumber: (value: string) => value, + localeCompare: (first: string, second: string) => first.localeCompare(second), })), ); @@ -98,6 +158,7 @@ jest.mock('@userActions/Session', () => ({ signOutAndRedirectToSignIn: jest.fn(), })); +const mockUsePersonalDetails = jest.mocked(usePersonalDetails); const mockUseAskConcierge = jest.mocked(useAskConcierge); const mockUseResponsiveLayout = jest.mocked(useResponsiveLayout); const mockUseKeyboardState = jest.mocked(useKeyboardState); @@ -166,14 +227,68 @@ function pasteImage() { fireEvent(getInput(), 'paste', {nativeEvent: {items: [{type: 'image/png', data: 'file:///image.png'}]}}); } +function typeText(text: string) { + fireEvent(getInput(), 'focus'); + fireEvent.changeText(getInput(), text); + fireEvent(getInput(), 'selectionChange', {nativeEvent: {selection: {start: text.length, end: text.length}}}); + + act(() => jest.advanceTimersByTime(CONST.TIMING.SUGGESTION_DEBOUNCE_TIME)); +} + +function scrollInput(offsetY = 40) { + fireEvent(getInput(), 'scroll', {nativeEvent: {contentOffset: {y: offsetY}}}); +} + +function settleLayoutTriggeredScroll() { + act(() => jest.advanceTimersByTime(SCROLL_LAYOUT_TRIGGER_RESET_TIME)); +} + +/** + * On web the composer's key event carries `key`/`shiftKey` both at the top level (where the suggestion layer reads them) + * and on `nativeEvent` (where the submit handler reads them), so tests that involve suggestions must set both. + */ +function pressKeyWithSuggestions(key: string, options?: {shiftKey?: boolean}) { + const shiftKey = options?.shiftKey ?? false; + fireEvent(getInput(), 'keyPress', { + key, + shiftKey, + preventDefault: jest.fn(), + nativeEvent: {key, shiftKey}, + }); +} + +function getLastMentionSuggestionsProps(): MentionSuggestionsProps { + const props = mockMentionSuggestionsSpy.mock.calls.at(-1)?.[0]; + if (!props) { + throw new Error('Expected mention suggestions to have rendered'); + } + return props; +} + +function getLastEmojiSuggestionsProps(): EmojiSuggestionsProps { + const props = mockEmojiSuggestionsSpy.mock.calls.at(-1)?.[0]; + if (!props) { + throw new Error('Expected emoji suggestions to have rendered'); + } + return props; +} + function measureLongPlaceholder(height: number) { fireEvent(screen.getByText(LONG_PLACEHOLDER), 'layout', {nativeEvent: {layout: {height}}}); } describe('ConciergePromptBox', () => { - beforeEach(() => { + beforeEach(async () => { jest.clearAllMocks(); + await act(async () => { + await Onyx.set(ONYXKEYS.CONCIERGE_PROMPT_DRAFT, null); + }); pickerHandler.onConfirm = undefined; + mockPersonalDetails = { + [TEAMMATE.accountID]: TEAMMATE, + [OTHER_TEAMMATE.accountID]: OTHER_TEAMMATE, + }; + mockUsePersonalDetails.mockImplementation(() => mockPersonalDetails); setAskConcierge(); setResponsiveLayout(false); setKeyboardShown(false); @@ -222,6 +337,213 @@ describe('ConciergePromptBox', () => { }); }); + describe('mentions', () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); + + it('suggests users matching what was typed after the @', () => { + // Given a rendered prompt box + render(); + + // When a mention prefix is typed + typeText('Show me @ale'); + + // Then only the matching user is offered + const {mentions, prefix} = getLastMentionSuggestionsProps(); + expect(prefix).toBe('ale'); + expect(mentions.map((mention) => mention.handle)).toEqual(['alex@expensify.com']); + }); + + it('does not suggest anything without an @', () => { + // Given a rendered prompt box + render(); + + // When plain text is typed + typeText('Show me my expenses'); + + // Then no picker is rendered + expect(screen.queryByTestId('mention-suggestions')).not.toBeOnTheScreen(); + }); + + it('inserts the selected mention into the prompt', () => { + // Given a visible suggestion list + render(); + typeText('Show me @ale'); + + // When the suggestion is selected + act(() => getLastMentionSuggestionsProps().onSelect(0)); + + // Then the typed prefix is replaced by the full mention and the list closes + expect(getInput()).toHaveDisplayValue('Show me @alex@expensify.com '); + expect(screen.queryByTestId('mention-suggestions')).not.toBeOnTheScreen(); + }); + + it('selects the mention on Enter instead of sending the prompt', () => { + // Given a visible suggestion list + render(); + typeText('Show me @ale'); + + // When Enter is pressed + pressKeyWithSuggestions(CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey); + + // Then the mention is inserted and the half-typed prompt is not sent + expect(getInput()).toHaveDisplayValue('Show me @alex@expensify.com '); + expect(mockAskConcierge).not.toHaveBeenCalled(); + }); + + it('still sends on Enter once no suggestions are showing', () => { + // Given a prompt with no open suggestion list + render(); + typeText('Show me my expenses'); + + // When Enter is pressed + pressKeyWithSuggestions(CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey); + + // Then the prompt is sent as usual + expect(mockAskConcierge).toHaveBeenCalledWith('Show me my expenses'); + }); + + it('dismisses the suggestions on Escape', () => { + // Given a visible suggestion list + render(); + typeText('Show me @ale'); + + // When Escape is pressed + pressKeyWithSuggestions(CONST.KEYBOARD_SHORTCUTS.ESCAPE.shortcutKey); + + // Then the list closes and the typed text is left alone + expect(screen.queryByTestId('mention-suggestions')).not.toBeOnTheScreen(); + expect(getInput()).toHaveDisplayValue('Show me @ale'); + }); + + it('hides the suggestions when the input loses focus', () => { + // Given a visible suggestion list + render(); + typeText('Show me @ale'); + expect(screen.getByTestId('mention-suggestions')).toBeOnTheScreen(); + + // When the input is blurred + fireEvent(getInput(), 'blur'); + + // Then the list closes + expect(screen.queryByTestId('mention-suggestions')).not.toBeOnTheScreen(); + }); + }); + + describe('emojis', () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); + + it('suggests emojis matching what was typed after the colon', () => { + // Given a rendered prompt box + render(); + + // When an emoji code is typed + typeText('Nice work :smile'); + + // Then the matching emojis are offered + const {emojis, prefix} = getLastEmojiSuggestionsProps(); + expect(prefix).toBe('smile'); + expect(emojis.map((emoji) => emoji.name)).toContain('smile'); + }); + + it('does not suggest anything without a colon', () => { + // Given a rendered prompt box + render(); + + // When plain text is typed + typeText('Nice work'); + + // Then no picker is rendered + expect(screen.queryByTestId('emoji-suggestions')).not.toBeOnTheScreen(); + }); + + it('inserts the selected emoji into the prompt', () => { + // Given a visible suggestion list + render(); + typeText('Nice work :smile'); + const {emojis} = getLastEmojiSuggestionsProps(); + + // When the first suggestion is selected + act(() => getLastEmojiSuggestionsProps().onSelect(0)); + + // Then the typed code is replaced by the emoji itself and the list closes + expect(getInput()).toHaveDisplayValue(`Nice work ${emojis.at(0)?.code} `); + expect(screen.queryByTestId('emoji-suggestions')).not.toBeOnTheScreen(); + }); + + it('selects the emoji on Enter instead of sending the prompt', () => { + // Given a visible suggestion list + render(); + typeText('Nice work :smile'); + const {emojis} = getLastEmojiSuggestionsProps(); + + // When Enter is pressed + pressKeyWithSuggestions(CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey); + + // Then the emoji is inserted and the half-typed prompt is not sent + expect(getInput()).toHaveDisplayValue(`Nice work ${emojis.at(0)?.code} `); + expect(mockAskConcierge).not.toHaveBeenCalled(); + }); + + it('dismisses the suggestions on Escape', () => { + // Given a visible suggestion list + render(); + typeText('Nice work :smile'); + + // When Escape is pressed + pressKeyWithSuggestions(CONST.KEYBOARD_SHORTCUTS.ESCAPE.shortcutKey); + + // Then the list closes and the typed text is left alone + expect(screen.queryByTestId('emoji-suggestions')).not.toBeOnTheScreen(); + expect(getInput()).toHaveDisplayValue('Nice work :smile'); + }); + + it('hides the suggestions when the input loses focus', () => { + // Given a visible suggestion list + render(); + typeText('Nice work :smile'); + expect(screen.getByTestId('emoji-suggestions')).toBeOnTheScreen(); + + // When the input is blurred + fireEvent(getInput(), 'blur'); + + // Then the list closes + expect(screen.queryByTestId('emoji-suggestions')).not.toBeOnTheScreen(); + }); + }); + + describe('scrolling the input', () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); + + it('hides the suggestions when the input is scrolled', () => { + // Given a visible suggestion list that is no longer settling after the text change + render(); + typeText('Show me @ale'); + settleLayoutTriggeredScroll(); + expect(screen.getByTestId('mention-suggestions')).toBeOnTheScreen(); + + // When the input is scrolled + scrollInput(); + + // Then the list closes, so it never floats away from the text it belongs to + expect(screen.queryByTestId('mention-suggestions')).not.toBeOnTheScreen(); + }); + + it('keeps the suggestions open when the text change itself moved the input', () => { + // Given a visible suggestion list right after typing, where the input reflows and fires onScroll on its own + render(); + typeText('Show me @ale'); + + // When that layout-triggered scroll arrives + scrollInput(); + + // Then the list stays open, since the user never scrolled + expect(screen.getByTestId('mention-suggestions')).toBeOnTheScreen(); + }); + }); + describe('Enter key', () => { it('submits on Enter', () => { // Given a typed message diff --git a/tests/unit/pages/HomePage.test.tsx b/tests/unit/pages/HomePage.test.tsx index 42b1fd012ffa..2a1e19fe145d 100644 --- a/tests/unit/pages/HomePage.test.tsx +++ b/tests/unit/pages/HomePage.test.tsx @@ -9,6 +9,7 @@ import OnyxListItemProvider from '@src/components/OnyxListItemProvider'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import {PortalProvider} from '@gorhom/portal'; import React from 'react'; import Onyx from 'react-native-onyx'; @@ -63,6 +64,16 @@ jest.mock('@components/Navigation/TopBar', () => { } return MockTopBar; }); + +jest.mock('@gorhom/portal', () => { + const ReactModule = require('react'); + const {View: RNView} = require('react-native'); + return { + ...jest.requireActual('@gorhom/portal'), + PortalHost: ({name}: {name: string}) => ReactModule.createElement(RNView, {testID: `portal-host-${name}`}), + }; +}); + jest.mock('@components/ReceiptScanDropZone', () => { function MockReceiptScanDropZone() { return null; @@ -117,9 +128,11 @@ function setWideLayout() { const renderHomePage = () => render( - - - , + + + + + , ); function renderedSectionOrder() { @@ -138,6 +151,23 @@ describe('HomePage', () => { await waitForBatchedUpdates(); }); + describe('suggestion portal host', () => { + it.each([ + ['narrow', setNarrowLayout], + ['wide', setWideLayout], + ])('renders the suggestions host on %s layout', async (_label, setLayout) => { + // Given a layout + setLayout(); + await waitForBatchedUpdates(); + + // When the Home page renders + renderHomePage(); + + // Then the suggestions portal host is on the page + expect(screen.getByTestId('portal-host-suggestions')).toBeOnTheScreen(); + }); + }); + // For you sits above Getting started on narrow layouts, regardless of the onboarding intent. describe('mobile ordering', () => { it.each([ From 54f26392619c7ade258106e0fd98fdca4e1d3553 Mon Sep 17 00:00:00 2001 From: GCyganek Date: Mon, 31 Aug 2026 16:48:21 +0200 Subject: [PATCH 02/14] Fix knip and ESlint --- .../inbox/report/ReportActionCompose/useComposerSuggestions.ts | 1 - tests/ui/ConciergePromptBoxTest.tsx | 2 -- 2 files changed, 3 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/useComposerSuggestions.ts b/src/pages/inbox/report/ReportActionCompose/useComposerSuggestions.ts index abbc268c87ed..21f768e95792 100644 --- a/src/pages/inbox/report/ReportActionCompose/useComposerSuggestions.ts +++ b/src/pages/inbox/report/ReportActionCompose/useComposerSuggestions.ts @@ -112,4 +112,3 @@ function useComposerSuggestions({composerRef, selection, measureParentContainer, } export default useComposerSuggestions; -export type {UseComposerSuggestionsParams}; diff --git a/tests/ui/ConciergePromptBoxTest.tsx b/tests/ui/ConciergePromptBoxTest.tsx index 771af348e0d1..b6751ea1b6dc 100644 --- a/tests/ui/ConciergePromptBoxTest.tsx +++ b/tests/ui/ConciergePromptBoxTest.tsx @@ -22,8 +22,6 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {PersonalDetailsList} from '@src/types/onyx'; import type {FileObject} from '@src/types/utils/Attachment'; -import type {ViewProps} from 'react-native'; - import React, {useState} from 'react'; import Onyx from 'react-native-onyx'; From 156625a1600cdbd973a7625590106382a90005f3 Mon Sep 17 00:00:00 2001 From: GCyganek Date: Tue, 1 Sep 2026 10:26:22 +0200 Subject: [PATCH 03/14] onBlur instead of useEffect --- src/pages/home/ForYouSection/ConciergePromptBox.tsx | 12 ++++-------- .../inbox/report/ReportActionItemMessageEdit.tsx | 13 ++++--------- 2 files changed, 8 insertions(+), 17 deletions(-) diff --git a/src/pages/home/ForYouSection/ConciergePromptBox.tsx b/src/pages/home/ForYouSection/ConciergePromptBox.tsx index 6dbab0118433..65b81218b314 100644 --- a/src/pages/home/ForYouSection/ConciergePromptBox.tsx +++ b/src/pages/home/ForYouSection/ConciergePromptBox.tsx @@ -137,13 +137,6 @@ function ConciergePromptBox({isMenuVisible, setIsMenuVisible}: ConciergePromptBo debouncedSaveDraft(text); }; - useEffect(() => { - if (isFocused) { - return; - } - hideSuggestionMenu(); - }, [isFocused, hideSuggestionMenu]); - const clearInput = () => { setValue(''); setSelection({start: 0, end: 0}); @@ -330,7 +323,10 @@ function ConciergePromptBox({isMenuVisible, setIsMenuVisible}: ConciergePromptBo onSelectionChange={(event) => setSelection(event.nativeEvent.selection)} shouldCalculateCaretPosition onFocus={() => setIsFocused(true)} - onBlur={() => setIsFocused(false)} + onBlur={() => { + setIsFocused(false); + hideSuggestionMenu(); + }} onKeyPress={handleKeyPress} onPasteFile={(files) => { // Concierge isn't reachable yet, so there is nowhere to send the paste. Mirrors the disabled "+" button. diff --git a/src/pages/inbox/report/ReportActionItemMessageEdit.tsx b/src/pages/inbox/report/ReportActionItemMessageEdit.tsx index 4846090b469c..3b28411f5589 100644 --- a/src/pages/inbox/report/ReportActionItemMessageEdit.tsx +++ b/src/pages/inbox/report/ReportActionItemMessageEdit.tsx @@ -313,14 +313,6 @@ function ReportActionItemMessageEdit({action, reportID, originalReportID, policy debouncedCommentMaxLengthValidation(draft); }, [draft, debouncedCommentMaxLengthValidation]); - useEffect(() => { - if (isFocused) { - return; - } - - hideSuggestionMenu(); - }, [isFocused, hideSuggestionMenu]); - return ( <> setIsFocused(false)} + onBlur={() => { + setIsFocused(false); + hideSuggestionMenu(); + }} selection={selection} onSelectionChange={(e) => setSelection(e.nativeEvent.selection)} isGroupPolicyReport={isGroupPolicyReport} From 92947a964c8386c0a396774da89ff6a9f17cf904 Mon Sep 17 00:00:00 2001 From: GCyganek Date: Thu, 3 Sep 2026 14:33:19 +0200 Subject: [PATCH 04/14] Fix not hiding suggestions when navigating to a different screen --- src/pages/home/ForYouSection/ConciergePromptBox.tsx | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/pages/home/ForYouSection/ConciergePromptBox.tsx b/src/pages/home/ForYouSection/ConciergePromptBox.tsx index 65b81218b314..1f2adeba6312 100644 --- a/src/pages/home/ForYouSection/ConciergePromptBox.tsx +++ b/src/pages/home/ForYouSection/ConciergePromptBox.tsx @@ -45,6 +45,7 @@ import type {FileObject} from '@src/types/utils/Attachment'; import type {NativeMethods, TextInputKeyPressEvent} from 'react-native'; +import {useIsFocused} from '@react-navigation/core'; import React, {useEffect, useRef, useState} from 'react'; import {View} from 'react-native'; import {useAnimatedRef} from 'react-native-reanimated'; @@ -81,6 +82,7 @@ function ConciergePromptBox({isMenuVisible, setIsMenuVisible}: ConciergePromptBo const {calculatePopoverPosition} = usePopoverPosition(); const [draft] = useOnyx(ONYXKEYS.CONCIERGE_PROMPT_DRAFT); const [value, setValue] = useState(draft ?? ''); + const isScreenFocused = useIsFocused(); const {debouncedCommentMaxLengthValidation, exceededMaxLength, isExceedingMaxLength, isTaskTitle} = useDebouncedCommentMaxLengthValidation({reportID: conciergeTargetReportID}); @@ -213,6 +215,14 @@ function ConciergePromptBox({isMenuVisible, setIsMenuVisible}: ConciergePromptBo submit(); }; + useEffect(() => { + if (isScreenFocused) { + return; + } + + hideSuggestionMenu(); + }, [isScreenFocused, hideSuggestionMenu]); + return ( From 68e77edc5a1c4ec4c1021fadd3423c0fe0e1ab45 Mon Sep 17 00:00:00 2001 From: GCyganek Date: Thu, 3 Sep 2026 15:56:41 +0200 Subject: [PATCH 05/14] Fix suggestions positioning --- .../index.android.ts | 35 ------------- .../{index.ios.ts => index.native.ts} | 0 .../index.native.tsx | 52 +++++++++++++++---- .../AutoCompleteSuggestionsPortal/index.tsx | 4 +- .../AutoCompleteSuggestionsPortal/types.ts | 3 ++ .../AutoCompleteSuggestions/index.tsx | 7 ++- 6 files changed, 54 insertions(+), 47 deletions(-) delete mode 100644 src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/getBottomSuggestionPadding/index.android.ts rename src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/getBottomSuggestionPadding/{index.ios.ts => index.native.ts} (100%) diff --git a/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/getBottomSuggestionPadding/index.android.ts b/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/getBottomSuggestionPadding/index.android.ts deleted file mode 100644 index 074a89d859b5..000000000000 --- a/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/getBottomSuggestionPadding/index.android.ts +++ /dev/null @@ -1,35 +0,0 @@ -import {Dimensions} from 'react-native'; - -function getBottomSuggestionPadding(bottom: number, isInLandscapeMode: boolean): number { - const {height} = Dimensions.get('window'); - const basePadding = 30; - - if (isInLandscapeMode) { - return basePadding; - } - - // Calculate what percentage of the screen height the bottom position represents - const bottomPercentageToHeight = (bottom / height) * 100; - - // Reference values for interpolation - const referenceHeightMin = 900; - const referencePaddingMin = -10; - const referenceHeightMax = 1000; - const referencePaddingMax = -44; - - // If the bottom position is more than 7% of the screen, the value is too big and we need adjust the padding - if (bottomPercentageToHeight > 7) { - // Calculate the rate of change between reference points - const paddingRate = (referencePaddingMax - referencePaddingMin) / (referenceHeightMax - referenceHeightMin); - - // Interpolate the padding value based on the current screen height - const padding = referencePaddingMin + (height - referenceHeightMin) * paddingRate; - - // Clamp the padding value between -60 and -5 to prevent extreme values - return Math.round(Math.max(-60, Math.min(-5, padding))); - } - - return basePadding; -} - -export default getBottomSuggestionPadding; diff --git a/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/getBottomSuggestionPadding/index.ios.ts b/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/getBottomSuggestionPadding/index.native.ts similarity index 100% rename from src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/getBottomSuggestionPadding/index.ios.ts rename to src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/getBottomSuggestionPadding/index.native.ts diff --git a/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/index.native.tsx b/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/index.native.tsx index 44fd2c4d2d62..65feb02846c8 100644 --- a/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/index.native.tsx +++ b/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/index.native.tsx @@ -1,9 +1,13 @@ import BaseAutoCompleteSuggestions from '@components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions'; import useStyleUtils from '@hooks/useStyleUtils'; +import useThemeStyles from '@hooks/useThemeStyles'; +import useWindowDimensionsForAutoCompleteSuggestions from '@hooks/useWindowDimensionsForAutoCompleteSuggestions'; + +import variables from '@styles/variables'; import {Portal} from '@gorhom/portal'; -import React, {useMemo} from 'react'; +import React, {useEffect, useRef, useState} from 'react'; import {View} from 'react-native'; import type {AutoCompleteSuggestionsPortalProps} from './types'; @@ -11,17 +15,33 @@ import type {AutoCompleteSuggestionsPortalProps} from './types'; import getBottomSuggestionPadding from './getBottomSuggestionPadding'; import TransparentOverlay from './TransparentOverlay/TransparentOverlay'; +const zIndexStyle = {zIndex: variables.autoCompleteSuggestionsZIndex}; + function AutoCompleteSuggestionsPortal({ left = 0, width = 0, bottom = 0, + keyboardHeight = 0, resetSuggestions = () => {}, isInLandscapeMode = false, ...props }: AutoCompleteSuggestionsPortalProps) { const StyleUtils = useStyleUtils(); + const styles = useThemeStyles(); + const {height: windowHeight} = useWindowDimensionsForAutoCompleteSuggestions(); + const hostFrameRef = useRef(null); + const [hostFrameBottom, setHostFrameBottom] = useState(null); + + // Re-base `bottom` (measured from the window bottom, offset by the keyboard) onto the portal host's own frame, which can sit higher on screens with bottom-docked content. + const measureHostFrame = () => { + hostFrameRef.current?.measureInWindow((x, y, frameWidth, height) => setHostFrameBottom(y + height)); + }; + useEffect(measureHostFrame, [windowHeight, keyboardHeight, bottom]); + + const hostRelativeBottom = hostFrameBottom === null ? 0 : bottom + keyboardHeight - (windowHeight - hostFrameBottom); + const isHostFrameMeasured = hostFrameBottom !== null; const bottomPadding = getBottomSuggestionPadding(bottom, isInLandscapeMode); - const styles = useMemo(() => StyleUtils.getBaseAutoCompleteSuggestionContainerStyle({left, width, bottom: bottom + bottomPadding}), [StyleUtils, left, width, bottom, bottomPadding]); + const containerStyle = StyleUtils.getBaseAutoCompleteSuggestionContainerStyle({left, width, bottom: hostRelativeBottom + bottomPadding}); if (!width) { return null; @@ -29,13 +49,27 @@ function AutoCompleteSuggestionsPortal({ return ( - - - - width={width} - {...props} - /> - + {/* Zero-cost probe filling the portal host, so the host's position in the window is known before positioning the suggestions. */} + + {isHostFrameMeasured && ( + <> + + + + width={width} + {...props} + /> + + + )} ); } diff --git a/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/index.tsx b/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/index.tsx index 41364b30297f..a0d154c4550e 100644 --- a/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/index.tsx +++ b/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/index.tsx @@ -28,8 +28,10 @@ function AutoCompleteSuggestionsPortal({ left = 0, width = 0, bottom = 0, + // keyboardHeight and isInLandscapeMode are only used on native platforms to adjust the bottom position + // eslint-disable-next-line @typescript-eslint/no-unused-vars + keyboardHeight = 0, resetSuggestions = () => {}, - // isInLandscapeMode is only used on native platforms to adjust the bottom padding // eslint-disable-next-line @typescript-eslint/no-unused-vars isInLandscapeMode = false, ...props diff --git a/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/types.ts b/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/types.ts index 62fcc5afd77a..916e2d930725 100644 --- a/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/types.ts +++ b/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/types.ts @@ -6,6 +6,9 @@ type AutoCompleteSuggestionsPortalProps = ExternalProps({measureParentContainerAndReportCu const newLeftOffset = getLeftOffset(x, insets, bigScreenLeftOffset, shouldUseNarrowLayout, width, windowWidth, isInLandscapeMode); // If the suggested word is longer than 150 (approximately half the width of the suggestion popup), then adjust a new position of popup const isAdjustmentNeeded = Math.abs(prevLeftValue.current - bigScreenLeftOffset) > 150; - if (isInitialRender.current || isAdjustmentNeeded || prevIsInLandscapeModeValue.current !== isInLandscapeMode) { - isSuggestionMenuAboveRef.current = isSuggestionMenuRenderedAbove(isEnoughSpaceToRenderMenuAboveForBig, isEnoughSpaceToRenderMenuAboveForSmall); + const shouldRenderMenuAbove = isSuggestionMenuRenderedAbove(isEnoughSpaceToRenderMenuAboveForBig, isEnoughSpaceToRenderMenuAboveForSmall); + const hasRunOutOfSpaceAbove = isSuggestionMenuAboveRef.current && !shouldRenderMenuAbove; + if (isInitialRender.current || isAdjustmentNeeded || prevIsInLandscapeModeValue.current !== isInLandscapeMode || hasRunOutOfSpaceAbove) { + isSuggestionMenuAboveRef.current = shouldRenderMenuAbove; leftValue.current = newLeftOffset; isInitialRender.current = false; prevLeftValue.current = newLeftOffset; @@ -191,6 +193,7 @@ function AutoCompleteSuggestions({measureParentContainerAndReportCu left={containerState.left} width={containerState.width} bottom={containerState.bottom} + keyboardHeight={keyboardHeight} measuredHeightOfSuggestionRows={suggestionHeight} isInLandscapeMode={isInLandscapeMode} /> From 97571820a0a2c07a0bf5fdd280119a3edfd4d409 Mon Sep 17 00:00:00 2001 From: GCyganek Date: Thu, 3 Sep 2026 16:03:00 +0200 Subject: [PATCH 06/14] Fix tests --- tests/ui/ConciergePromptBoxTest.tsx | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/ui/ConciergePromptBoxTest.tsx b/tests/ui/ConciergePromptBoxTest.tsx index d93f748b7abb..1b257ab7c864 100644 --- a/tests/ui/ConciergePromptBoxTest.tsx +++ b/tests/ui/ConciergePromptBoxTest.tsx @@ -22,8 +22,10 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {PersonalDetailsList} from '@src/types/onyx'; import type {FileObject} from '@src/types/utils/Attachment'; +import type * as NavigationCore from '@react-navigation/core'; import type {ViewProps} from 'react-native'; +import {useIsFocused} from '@react-navigation/core'; import React, {useState} from 'react'; import Onyx from 'react-native-onyx'; @@ -138,6 +140,11 @@ jest.mock('@hooks/useLocalize', () => })), ); +jest.mock('@react-navigation/core', () => ({ + ...jest.requireActual('@react-navigation/core'), + useIsFocused: jest.fn(() => true), +})); + jest.mock('@hooks/useResponsiveLayout', () => jest.fn()); jest.mock('@hooks/useKeyboardState', () => jest.fn()); @@ -160,6 +167,7 @@ jest.mock('@userActions/Session', () => ({ const mockUsePersonalDetails = jest.mocked(usePersonalDetails); const mockUseAskConcierge = jest.mocked(useAskConcierge); +const mockUseIsFocused = jest.mocked(useIsFocused); const mockUseResponsiveLayout = jest.mocked(useResponsiveLayout); const mockUseKeyboardState = jest.mocked(useKeyboardState); const mockIsSafari = jest.mocked(isSafari); @@ -294,6 +302,7 @@ describe('ConciergePromptBox', () => { setKeyboardShown(false); mockIsSafari.mockReturnValue(false); mockIsAnonymousUser.mockReturnValue(false); + mockUseIsFocused.mockReturnValue(true); }); describe('sending a message', () => { @@ -428,6 +437,20 @@ describe('ConciergePromptBox', () => { // Then the list closes expect(screen.queryByTestId('mention-suggestions')).not.toBeOnTheScreen(); }); + + it('hides the suggestions when the screen loses focus', () => { + // Given a visible suggestion list + render(); + typeText('Show me @ale'); + expect(screen.getByTestId('mention-suggestions')).toBeOnTheScreen(); + + // When the screen is navigated away from + mockUseIsFocused.mockReturnValue(false); + screen.rerender(); + + // Then the list closes + expect(screen.queryByTestId('mention-suggestions')).not.toBeOnTheScreen(); + }); }); describe('emojis', () => { From 9361fc35baa4b071a1ce9d360fef50e9239e495a Mon Sep 17 00:00:00 2001 From: GCyganek Date: Mon, 7 Sep 2026 11:02:13 +0200 Subject: [PATCH 07/14] Fix ESlint and ts --- tests/ui/ConciergePromptBoxTest.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/ui/ConciergePromptBoxTest.tsx b/tests/ui/ConciergePromptBoxTest.tsx index 1b257ab7c864..353fec45f42f 100644 --- a/tests/ui/ConciergePromptBoxTest.tsx +++ b/tests/ui/ConciergePromptBoxTest.tsx @@ -2,7 +2,7 @@ import {act, fireEvent, render, screen, waitFor} from '@testing-library/react-na import type {Emoji} from '@assets/emojis/types'; -import type {Mention} from '@components/MentionSuggestions'; +import type Mention from '@components/MentionSuggestions/types'; import {usePersonalDetails} from '@components/OnyxListItemProvider'; import useAskConcierge from '@components/Search/SearchRouter/useAskConcierge'; From 74b36790797c01c97ddf37d60eed5bd80b62289a Mon Sep 17 00:00:00 2001 From: GCyganek Date: Mon, 7 Sep 2026 12:14:47 +0200 Subject: [PATCH 08/14] Fix iOS mweb --- .../AutoCompleteSuggestionsPortal/index.tsx | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/index.tsx b/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/index.tsx index a0d154c4550e..e1ada9b097d6 100644 --- a/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/index.tsx +++ b/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/index.tsx @@ -17,6 +17,17 @@ import TransparentOverlay from './TransparentOverlay/TransparentOverlay'; const zIndexStyle = {zIndex: variables.autoCompleteSuggestionsZIndex}; +/** Keyboard height on WebKit, where the keyboard shrinks the visual viewport that `bottom` is derived from but not the layout viewport it is applied to. 0 elsewhere. */ +function getVisualViewportBottomOffset() { + const visualViewport = window.visualViewport; + + if (!visualViewport) { + return 0; + } + + return Math.max(0, document.documentElement.clientHeight - Math.round(visualViewport.height * visualViewport.scale)); +} + /** * On the mobile-web platform, when long-pressing on auto-complete suggestions, * we need to prevent focus shifting to avoid blurring the main input (which makes the suggestions picker close and fires the onSelect callback). @@ -56,7 +67,14 @@ function AutoCompleteSuggestionsPortal({ onPress={resetSuggestions} style={zIndexStyle} /> - {componentToRender} + + {componentToRender} + , bodyElement, ) From e1d5380f91554e9d47e7ffa088c5e3b443cb1e50 Mon Sep 17 00:00:00 2001 From: GCyganek Date: Tue, 8 Sep 2026 10:28:57 +0200 Subject: [PATCH 09/14] seatbelt --- config/eslint/eslint.seatbelt.tsv | 1 + 1 file changed, 1 insertion(+) diff --git a/config/eslint/eslint.seatbelt.tsv b/config/eslint/eslint.seatbelt.tsv index 1348c8053ff1..b52c63bfc9ac 100644 --- a/config/eslint/eslint.seatbelt.tsv +++ b/config/eslint/eslint.seatbelt.tsv @@ -910,6 +910,7 @@ "../../src/pages/EnablePayments/shared/IdologyQuestions.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/EnablePayments/Wallet/PersonalInfo/substeps/DateOfBirthStep.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../src/pages/EnablePayments/Wallet/utils/getSubstepValues.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../src/pages/home/ForYouSection/ConciergePromptBox.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/home/GettingStartedSection/hooks/useGettingStartedItems.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/home/GettingStartedSection/hooks/useGettingStartedItems.ts" "no-restricted-imports" 1 "../../src/pages/home/TimeSensitiveSection/items/FixCompanyCardConnection.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 From 236001657b2a5731fcab56deb55a836ee155ae77 Mon Sep 17 00:00:00 2001 From: GCyganek Date: Tue, 8 Sep 2026 15:01:43 +0200 Subject: [PATCH 10/14] Fix suggestions list on mWeb --- .../AutoCompleteSuggestionsPortal/index.tsx | 20 +------------------ .../index.native.ts | 8 ++++++++ .../getSuggestionsViewportBottom/index.ts | 9 +++++++++ .../AutoCompleteSuggestions/index.tsx | 7 +++++-- 4 files changed, 23 insertions(+), 21 deletions(-) create mode 100644 src/components/AutoCompleteSuggestions/getSuggestionsViewportBottom/index.native.ts create mode 100644 src/components/AutoCompleteSuggestions/getSuggestionsViewportBottom/index.ts diff --git a/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/index.tsx b/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/index.tsx index e1ada9b097d6..a0d154c4550e 100644 --- a/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/index.tsx +++ b/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/index.tsx @@ -17,17 +17,6 @@ import TransparentOverlay from './TransparentOverlay/TransparentOverlay'; const zIndexStyle = {zIndex: variables.autoCompleteSuggestionsZIndex}; -/** Keyboard height on WebKit, where the keyboard shrinks the visual viewport that `bottom` is derived from but not the layout viewport it is applied to. 0 elsewhere. */ -function getVisualViewportBottomOffset() { - const visualViewport = window.visualViewport; - - if (!visualViewport) { - return 0; - } - - return Math.max(0, document.documentElement.clientHeight - Math.round(visualViewport.height * visualViewport.scale)); -} - /** * On the mobile-web platform, when long-pressing on auto-complete suggestions, * we need to prevent focus shifting to avoid blurring the main input (which makes the suggestions picker close and fires the onSelect callback). @@ -67,14 +56,7 @@ function AutoCompleteSuggestionsPortal({ onPress={resetSuggestions} style={zIndexStyle} /> - - {componentToRender} - + {componentToRender} , bodyElement, ) diff --git a/src/components/AutoCompleteSuggestions/getSuggestionsViewportBottom/index.native.ts b/src/components/AutoCompleteSuggestions/getSuggestionsViewportBottom/index.native.ts new file mode 100644 index 000000000000..0176a47bbf92 --- /dev/null +++ b/src/components/AutoCompleteSuggestions/getSuggestionsViewportBottom/index.native.ts @@ -0,0 +1,8 @@ +/** + * On native the window height already describes the block the suggestion menu is positioned against. + */ +function getSuggestionsViewportBottom(windowHeight: number): number { + return windowHeight; +} + +export default getSuggestionsViewportBottom; diff --git a/src/components/AutoCompleteSuggestions/getSuggestionsViewportBottom/index.ts b/src/components/AutoCompleteSuggestions/getSuggestionsViewportBottom/index.ts new file mode 100644 index 000000000000..961554b327b5 --- /dev/null +++ b/src/components/AutoCompleteSuggestions/getSuggestionsViewportBottom/index.ts @@ -0,0 +1,9 @@ +/** + * The menu's `bottom` counts up from the `html` box, so that box has to be measured with the same `getBoundingClientRect` + * the caret position comes from. A window height is not interchangeable: iOS scrolls the page to reveal the keyboard. + */ +function getSuggestionsViewportBottom(windowHeight: number): number { + return document.documentElement?.getBoundingClientRect().bottom ?? windowHeight; +} + +export default getSuggestionsViewportBottom; diff --git a/src/components/AutoCompleteSuggestions/index.tsx b/src/components/AutoCompleteSuggestions/index.tsx index 7f431acf2394..d8080468e083 100644 --- a/src/components/AutoCompleteSuggestions/index.tsx +++ b/src/components/AutoCompleteSuggestions/index.tsx @@ -14,6 +14,7 @@ import type {AutoCompleteSuggestionsProps, MeasureParentContainerAndCursor} from import AutoCompleteSuggestionsPortal from './AutoCompleteSuggestionsPortal'; import getLeftOffset from './getSuggestionsLeftOffset'; +import getSuggestionsViewportBottom from './getSuggestionsViewportBottom'; const measureHeightOfSuggestionRows = (numRows: number, canBeBig: boolean, isInLandscapeMode: boolean): number => { if (isInLandscapeMode) { @@ -112,7 +113,9 @@ function AutoCompleteSuggestions({measureParentContainerAndReportCu : xCoordinatesOfCursor; const contentMaxHeight = measureHeightOfSuggestionRows(suggestionsLength, true, isInLandscapeMode); const contentMinHeight = measureHeightOfSuggestionRows(suggestionsLength, false, isInLandscapeMode); - let bottomValue = windowHeight - (cursorCoordinates.y - scrollValue + y) - keyboardHeight; + // Read in the same tick as the composer measurement above, so both describe the same layout state. + const viewportBottom = getSuggestionsViewportBottom(windowHeight); + let bottomValue = viewportBottom - (cursorCoordinates.y - scrollValue + y) - keyboardHeight; const widthValue = shouldUseNarrowLayout ? width : CONST.AUTO_COMPLETE_SUGGESTER.BIG_SCREEN_SUGGESTION_WIDTH; const isEnoughSpaceToRenderMenuAboveForBig = isEnoughSpaceToRenderMenuAboveCursor({ @@ -157,7 +160,7 @@ function AutoCompleteSuggestions({measureParentContainerAndReportCu } else { // calculation for big suggestion box below the cursor measuredHeight = measureHeightOfSuggestionRows(suggestionsLength, true, isInLandscapeMode); - bottomValue = windowHeight - y - cursorCoordinates.y + scrollValue - measuredHeight - CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT - keyboardHeight; + bottomValue = viewportBottom - y - cursorCoordinates.y + scrollValue - measuredHeight - CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT - keyboardHeight; } setSuggestionHeight(measuredHeight); From 88f175bbc44cb3b3dd01313399286dabcaca57cc Mon Sep 17 00:00:00 2001 From: GCyganek Date: Tue, 8 Sep 2026 15:49:23 +0200 Subject: [PATCH 11/14] Do not apply bottom padding when menu displayed below input --- .../getBottomSuggestionPadding/index.native.ts | 5 ++--- .../getBottomSuggestionPadding/index.ts | 2 +- .../AutoCompleteSuggestionsPortal/index.native.tsx | 6 ++++-- .../AutoCompleteSuggestionsPortal/index.tsx | 9 +++++---- .../AutoCompleteSuggestionsPortal/types.ts | 4 +++- src/components/AutoCompleteSuggestions/index.tsx | 7 ++++++- 6 files changed, 21 insertions(+), 12 deletions(-) diff --git a/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/getBottomSuggestionPadding/index.native.ts b/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/getBottomSuggestionPadding/index.native.ts index b82e2bc0a66b..d7c7b47a0021 100644 --- a/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/getBottomSuggestionPadding/index.native.ts +++ b/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/getBottomSuggestionPadding/index.native.ts @@ -1,6 +1,5 @@ -// eslint-disable-next-line @typescript-eslint/no-unused-vars -function getBottomSuggestionPadding(bottom?: number, isInLandscapeMode?: boolean): number { - return 30; +function getBottomSuggestionPadding(isMenuAbove: boolean): number { + return isMenuAbove ? 30 : 0; } export default getBottomSuggestionPadding; diff --git a/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/getBottomSuggestionPadding/index.ts b/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/getBottomSuggestionPadding/index.ts index a159938366a1..0a543ccbd775 100644 --- a/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/getBottomSuggestionPadding/index.ts +++ b/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/getBottomSuggestionPadding/index.ts @@ -1,5 +1,5 @@ // eslint-disable-next-line @typescript-eslint/no-unused-vars -function getBottomSuggestionPadding(bottom?: number, isInLandscapeMode?: boolean): number { +function getBottomSuggestionPadding(isMenuAbove: boolean): number { return 6; } diff --git a/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/index.native.tsx b/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/index.native.tsx index 65feb02846c8..c955cec401f5 100644 --- a/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/index.native.tsx +++ b/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/index.native.tsx @@ -23,7 +23,7 @@ function AutoCompleteSuggestionsPortal({ bottom = 0, keyboardHeight = 0, resetSuggestions = () => {}, - isInLandscapeMode = false, + isMenuAbove = false, ...props }: AutoCompleteSuggestionsPortalProps) { const StyleUtils = useStyleUtils(); @@ -40,13 +40,15 @@ function AutoCompleteSuggestionsPortal({ const hostRelativeBottom = hostFrameBottom === null ? 0 : bottom + keyboardHeight - (windowHeight - hostFrameBottom); const isHostFrameMeasured = hostFrameBottom !== null; - const bottomPadding = getBottomSuggestionPadding(bottom, isInLandscapeMode); + const bottomPadding = getBottomSuggestionPadding(isMenuAbove); const containerStyle = StyleUtils.getBaseAutoCompleteSuggestionContainerStyle({left, width, bottom: hostRelativeBottom + bottomPadding}); if (!width) { return null; } + console.log({bottomPadding}); + return ( {/* Zero-cost probe filling the portal host, so the host's position in the window is known before positioning the suggestions. */} diff --git a/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/index.tsx b/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/index.tsx index a0d154c4550e..bbaec2950d11 100644 --- a/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/index.tsx +++ b/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/index.tsx @@ -28,12 +28,11 @@ function AutoCompleteSuggestionsPortal({ left = 0, width = 0, bottom = 0, - // keyboardHeight and isInLandscapeMode are only used on native platforms to adjust the bottom position + // keyboardHeight and isMenuAbove are only used on native platforms to adjust the bottom position // eslint-disable-next-line @typescript-eslint/no-unused-vars keyboardHeight = 0, resetSuggestions = () => {}, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - isInLandscapeMode = false, + isMenuAbove = false, ...props }: AutoCompleteSuggestionsPortalProps): ReactElement | null | false { const StyleUtils = useStyleUtils(); @@ -56,7 +55,9 @@ function AutoCompleteSuggestionsPortal({ onPress={resetSuggestions} style={zIndexStyle} /> - {componentToRender} + + {componentToRender} + , bodyElement, ) diff --git a/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/types.ts b/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/types.ts index 916e2d930725..f01f8e54b6be 100644 --- a/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/types.ts +++ b/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/types.ts @@ -10,7 +10,9 @@ type AutoCompleteSuggestionsPortalProps = ExternalProps({measureParentContainerAndReportCu } let measuredHeight = 0; + const isMenuAbove = isSuggestionMenuAboveRef.current && (isEnoughSpaceToRenderMenuAboveForBig || isEnoughSpaceToRenderMenuAboveForSmall); if (isSuggestionMenuAboveRef.current && isEnoughSpaceToRenderMenuAboveForBig) { // calculation for big suggestion box above the cursor measuredHeight = measureHeightOfSuggestionRows(suggestionsLength, true, isInLandscapeMode); @@ -168,6 +170,7 @@ function AutoCompleteSuggestions({measureParentContainerAndReportCu left: leftValue.current, bottom: bottomValue, width: widthValue, + isMenuAbove, cursorCoordinates, }); }); @@ -190,6 +193,8 @@ function AutoCompleteSuggestions({measureParentContainerAndReportCu return null; } + console.log({isMenuAbove: containerState.isMenuAbove}); + return ( ({measureParentContainerAndReportCu bottom={containerState.bottom} keyboardHeight={keyboardHeight} measuredHeightOfSuggestionRows={suggestionHeight} - isInLandscapeMode={isInLandscapeMode} + isMenuAbove={containerState.isMenuAbove} /> ); } From 6c7796474e5d400dc4dce62b76d8c621fd75dced Mon Sep 17 00:00:00 2001 From: GCyganek Date: Wed, 9 Sep 2026 10:51:43 +0200 Subject: [PATCH 12/14] Delete console.logs --- .../AutoCompleteSuggestionsPortal/index.native.tsx | 2 -- src/components/AutoCompleteSuggestions/index.tsx | 2 -- 2 files changed, 4 deletions(-) diff --git a/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/index.native.tsx b/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/index.native.tsx index c955cec401f5..b11e158e4dda 100644 --- a/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/index.native.tsx +++ b/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/index.native.tsx @@ -47,8 +47,6 @@ function AutoCompleteSuggestionsPortal({ return null; } - console.log({bottomPadding}); - return ( {/* Zero-cost probe filling the portal host, so the host's position in the window is known before positioning the suggestions. */} diff --git a/src/components/AutoCompleteSuggestions/index.tsx b/src/components/AutoCompleteSuggestions/index.tsx index 4b86206130d9..ad5c226d5d2b 100644 --- a/src/components/AutoCompleteSuggestions/index.tsx +++ b/src/components/AutoCompleteSuggestions/index.tsx @@ -193,8 +193,6 @@ function AutoCompleteSuggestions({measureParentContainerAndReportCu return null; } - console.log({isMenuAbove: containerState.isMenuAbove}); - return ( Date: Wed, 9 Sep 2026 11:00:29 +0200 Subject: [PATCH 13/14] One getSuggestionsViewportBottom --- .../getSuggestionsViewportBottom.ts | 14 ++++++++++++++ .../getSuggestionsViewportBottom/index.native.ts | 8 -------- .../getSuggestionsViewportBottom/index.ts | 9 --------- 3 files changed, 14 insertions(+), 17 deletions(-) create mode 100644 src/components/AutoCompleteSuggestions/getSuggestionsViewportBottom.ts delete mode 100644 src/components/AutoCompleteSuggestions/getSuggestionsViewportBottom/index.native.ts delete mode 100644 src/components/AutoCompleteSuggestions/getSuggestionsViewportBottom/index.ts diff --git a/src/components/AutoCompleteSuggestions/getSuggestionsViewportBottom.ts b/src/components/AutoCompleteSuggestions/getSuggestionsViewportBottom.ts new file mode 100644 index 000000000000..b54a151ed5df --- /dev/null +++ b/src/components/AutoCompleteSuggestions/getSuggestionsViewportBottom.ts @@ -0,0 +1,14 @@ +/** + * The menu's `bottom` counts up from the `html` box, so on web that box has to be measured with the same + * `getBoundingClientRect` the caret position comes from. A window height is not interchangeable: iOS scrolls the page + * to reveal the keyboard. There is no DOM on native, so just use windowHeight; + */ +function getSuggestionsViewportBottom(windowHeight: number): number { + if (typeof document === 'undefined') { + return windowHeight; + } + + return document.documentElement?.getBoundingClientRect().bottom ?? windowHeight; +} + +export default getSuggestionsViewportBottom; diff --git a/src/components/AutoCompleteSuggestions/getSuggestionsViewportBottom/index.native.ts b/src/components/AutoCompleteSuggestions/getSuggestionsViewportBottom/index.native.ts deleted file mode 100644 index 0176a47bbf92..000000000000 --- a/src/components/AutoCompleteSuggestions/getSuggestionsViewportBottom/index.native.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * On native the window height already describes the block the suggestion menu is positioned against. - */ -function getSuggestionsViewportBottom(windowHeight: number): number { - return windowHeight; -} - -export default getSuggestionsViewportBottom; diff --git a/src/components/AutoCompleteSuggestions/getSuggestionsViewportBottom/index.ts b/src/components/AutoCompleteSuggestions/getSuggestionsViewportBottom/index.ts deleted file mode 100644 index 961554b327b5..000000000000 --- a/src/components/AutoCompleteSuggestions/getSuggestionsViewportBottom/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * The menu's `bottom` counts up from the `html` box, so that box has to be measured with the same `getBoundingClientRect` - * the caret position comes from. A window height is not interchangeable: iOS scrolls the page to reveal the keyboard. - */ -function getSuggestionsViewportBottom(windowHeight: number): number { - return document.documentElement?.getBoundingClientRect().bottom ?? windowHeight; -} - -export default getSuggestionsViewportBottom; From cfd95db1b5be254c54f6ff7e82be2a943316a393 Mon Sep 17 00:00:00 2001 From: GCyganek Date: Wed, 9 Sep 2026 12:04:46 +0200 Subject: [PATCH 14/14] Simplify logic --- .../index.native.tsx | 30 +++++++++---------- .../AutoCompleteSuggestionsPortal/index.tsx | 3 -- .../AutoCompleteSuggestionsPortal/types.ts | 3 -- .../getSuggestionsViewportBottom.ts | 14 --------- .../index.native.ts | 10 +++++++ .../getSuggestionsViewportBottom/index.ts | 10 +++++++ .../AutoCompleteSuggestions/index.tsx | 7 ++--- 7 files changed, 38 insertions(+), 39 deletions(-) delete mode 100644 src/components/AutoCompleteSuggestions/getSuggestionsViewportBottom.ts create mode 100644 src/components/AutoCompleteSuggestions/getSuggestionsViewportBottom/index.native.ts create mode 100644 src/components/AutoCompleteSuggestions/getSuggestionsViewportBottom/index.ts diff --git a/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/index.native.tsx b/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/index.native.tsx index b11e158e4dda..946d4168bbe6 100644 --- a/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/index.native.tsx +++ b/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/index.native.tsx @@ -1,14 +1,14 @@ import BaseAutoCompleteSuggestions from '@components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions'; +import useKeyboardState from '@hooks/useKeyboardState'; import useStyleUtils from '@hooks/useStyleUtils'; -import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensionsForAutoCompleteSuggestions from '@hooks/useWindowDimensionsForAutoCompleteSuggestions'; import variables from '@styles/variables'; import {Portal} from '@gorhom/portal'; import React, {useEffect, useRef, useState} from 'react'; -import {View} from 'react-native'; +import {StyleSheet, View} from 'react-native'; import type {AutoCompleteSuggestionsPortalProps} from './types'; @@ -21,27 +21,22 @@ function AutoCompleteSuggestionsPortal({ left = 0, width = 0, bottom = 0, - keyboardHeight = 0, resetSuggestions = () => {}, isMenuAbove = false, ...props }: AutoCompleteSuggestionsPortalProps) { const StyleUtils = useStyleUtils(); - const styles = useThemeStyles(); const {height: windowHeight} = useWindowDimensionsForAutoCompleteSuggestions(); + const {keyboardHeight} = useKeyboardState(); const hostFrameRef = useRef(null); - const [hostFrameBottom, setHostFrameBottom] = useState(null); - // Re-base `bottom` (measured from the window bottom, offset by the keyboard) onto the portal host's own frame, which can sit higher on screens with bottom-docked content. + const [hostBottomInset, setHostBottomInset] = useState(null); + const measureHostFrame = () => { - hostFrameRef.current?.measureInWindow((x, y, frameWidth, height) => setHostFrameBottom(y + height)); + hostFrameRef.current?.measureInWindow((x, y, frameWidth, height) => setHostBottomInset(windowHeight - y - height)); }; - useEffect(measureHostFrame, [windowHeight, keyboardHeight, bottom]); - const hostRelativeBottom = hostFrameBottom === null ? 0 : bottom + keyboardHeight - (windowHeight - hostFrameBottom); - const isHostFrameMeasured = hostFrameBottom !== null; - const bottomPadding = getBottomSuggestionPadding(isMenuAbove); - const containerStyle = StyleUtils.getBaseAutoCompleteSuggestionContainerStyle({left, width, bottom: hostRelativeBottom + bottomPadding}); + useEffect(measureHostFrame, [windowHeight, keyboardHeight]); if (!width) { return null; @@ -53,16 +48,21 @@ function AutoCompleteSuggestionsPortal({ - {isHostFrameMeasured && ( + {hostBottomInset !== null && ( <> - + width={width} {...props} diff --git a/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/index.tsx b/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/index.tsx index bbaec2950d11..84fa67621141 100644 --- a/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/index.tsx +++ b/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/index.tsx @@ -28,9 +28,6 @@ function AutoCompleteSuggestionsPortal({ left = 0, width = 0, bottom = 0, - // keyboardHeight and isMenuAbove are only used on native platforms to adjust the bottom position - // eslint-disable-next-line @typescript-eslint/no-unused-vars - keyboardHeight = 0, resetSuggestions = () => {}, isMenuAbove = false, ...props diff --git a/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/types.ts b/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/types.ts index f01f8e54b6be..0fc47283933e 100644 --- a/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/types.ts +++ b/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/types.ts @@ -6,9 +6,6 @@ type AutoCompleteSuggestionsPortalProps = ExternalProps({measureParentContainerAndReportCu const contentMaxHeight = measureHeightOfSuggestionRows(suggestionsLength, true, isInLandscapeMode); const contentMinHeight = measureHeightOfSuggestionRows(suggestionsLength, false, isInLandscapeMode); // Read in the same tick as the composer measurement above, so both describe the same layout state. - const viewportBottom = getSuggestionsViewportBottom(windowHeight); - let bottomValue = viewportBottom - (cursorCoordinates.y - scrollValue + y) - keyboardHeight; + const viewportBottom = getSuggestionsViewportBottom(windowHeight, keyboardHeight); + let bottomValue = viewportBottom - (cursorCoordinates.y - scrollValue + y); const widthValue = shouldUseNarrowLayout ? width : CONST.AUTO_COMPLETE_SUGGESTER.BIG_SCREEN_SUGGESTION_WIDTH; const isEnoughSpaceToRenderMenuAboveForBig = isEnoughSpaceToRenderMenuAboveCursor({ @@ -162,7 +162,7 @@ function AutoCompleteSuggestions({measureParentContainerAndReportCu } else { // calculation for big suggestion box below the cursor measuredHeight = measureHeightOfSuggestionRows(suggestionsLength, true, isInLandscapeMode); - bottomValue = viewportBottom - y - cursorCoordinates.y + scrollValue - measuredHeight - CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT - keyboardHeight; + bottomValue = viewportBottom - y - cursorCoordinates.y + scrollValue - measuredHeight - CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT; } setSuggestionHeight(measuredHeight); @@ -199,7 +199,6 @@ function AutoCompleteSuggestions({measureParentContainerAndReportCu left={containerState.left} width={containerState.width} bottom={containerState.bottom} - keyboardHeight={keyboardHeight} measuredHeightOfSuggestionRows={suggestionHeight} isMenuAbove={containerState.isMenuAbove} />