From 441b61c40bb8a6abc71e827c706a67d18dc70137 Mon Sep 17 00:00:00 2001 From: "huult (via MelvinBot)" Date: Wed, 28 Oct 2026 17:36:54 +0000 Subject: [PATCH 1/4] Show the full report field name in a tooltip when it is cut off Adds an opt-in shouldShowLabelTooltipWhenTruncated prop to the text input. On web, the label reports whether it is cut off (on layout and after its font-size animation settles), and the input container shows the full label in a tooltip on hover. Report fields turn this on for date, list, and text fields. GenericTooltip now keeps its children in the same place in the tree whether or not it has text, so toggling text no longer remounts the wrapped input and drops its focus. Co-authored-by: huult --- src/components/DatePicker/index.tsx | 2 + .../ReportFieldInlineInput.tsx | 3 + .../BaseTextInput/implementation/index.tsx | 456 +++++++++--------- .../TextInput/BaseTextInput/types.ts | 3 + .../TextInput/TextInputLabel/index.tsx | 28 +- .../TextInput/TextInputLabel/types.ts | 3 + src/components/Tooltip/GenericTooltip.tsx | 9 +- tests/unit/GenericTooltipTest.tsx | 37 ++ 8 files changed, 313 insertions(+), 228 deletions(-) create mode 100644 tests/unit/GenericTooltipTest.tsx diff --git a/src/components/DatePicker/index.tsx b/src/components/DatePicker/index.tsx index f86276f516dc..3e24e4390e73 100644 --- a/src/components/DatePicker/index.tsx +++ b/src/components/DatePicker/index.tsx @@ -57,6 +57,7 @@ function DatePicker({ icon, shouldEnableMonthYearBackdropInNarrowPane = false, textInputContainerStyles, + shouldShowLabelTooltipWhenTruncated, }: DateInputWithPickerProps) { const icons = useMemoizedLazyExpensifyIcons(['Calendar']); const styles = useThemeStyles(); @@ -261,6 +262,7 @@ function DatePicker({ autoComplete={autoComplete} disableKeyboard rightHandSideComponent={rightHandSideComponent} + shouldShowLabelTooltipWhenTruncated={shouldShowLabelTooltipWhenTruncated} /> diff --git a/src/components/MoneyRequestReportView/ReportFieldInlineInput.tsx b/src/components/MoneyRequestReportView/ReportFieldInlineInput.tsx index 33d566ceb5a5..20424f5866e7 100644 --- a/src/components/MoneyRequestReportView/ReportFieldInlineInput.tsx +++ b/src/components/MoneyRequestReportView/ReportFieldInlineInput.tsx @@ -186,6 +186,7 @@ function ReportFieldInlineInput({reportField, fieldKey, value, isDisabled, error // backdrop by default, so the report behind them would not be dimmed. shouldEnableMonthYearBackdropInNarrowPane={isInNarrowPaneModal} textInputContainerStyles={textInputContainerStyles} + shouldShowLabelTooltipWhenTruncated />, ); } @@ -273,6 +274,7 @@ function ReportFieldInlineInput({reportField, fieldKey, value, isDisabled, error // `disableKeyboard` only suppresses the software keyboard, it does not hide the caret. caretHidden textInputContainerStyles={textInputContainerStyles} + shouldShowLabelTooltipWhenTruncated /> ); @@ -298,6 +300,7 @@ function ReportFieldInlineInput({reportField, fieldKey, value, isDisabled, error onSubmitEditing={() => textInputRef.current?.blur()} ref={textInputRef} textInputContainerStyles={textInputContainerStyles} + shouldShowLabelTooltipWhenTruncated />, ); } diff --git a/src/components/TextInput/BaseTextInput/implementation/index.tsx b/src/components/TextInput/BaseTextInput/implementation/index.tsx index 53eb2d453c4c..cca492e4b2ee 100644 --- a/src/components/TextInput/BaseTextInput/implementation/index.tsx +++ b/src/components/TextInput/BaseTextInput/implementation/index.tsx @@ -15,6 +15,7 @@ import {ACTIVE_LABEL_SCALE, ACTIVE_LABEL_TRANSLATE_Y, INACTIVE_LABEL_SCALE, INAC import TextInputClearButton from '@components/TextInput/TextInputClearButton'; import TextInputLabel from '@components/TextInput/TextInputLabel'; import TextInputMeasurement from '@components/TextInput/TextInputMeasurement'; +import Tooltip from '@components/Tooltip'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; @@ -69,6 +70,7 @@ function BaseTextInput({ onInputChange = () => {}, multiline = false, shouldLabelStayOnSingleLine = false, + shouldShowLabelTooltipWhenTruncated = false, shouldInterceptSwipe = false, autoCorrect = true, prefixCharacter = '', @@ -121,6 +123,7 @@ function BaseTextInput({ const [width, setWidth] = useState(null); const [prefixCharacterPadding, setPrefixCharacterPadding] = useState(CONST.CHARACTER_WIDTH); const [isPrefixCharacterPaddingCalculated, setIsPrefixCharacterPaddingCalculated] = useState(() => !prefixCharacter); + const [isLabelTruncated, setIsLabelTruncated] = useState(false); const labelScale = useSharedValue(initialActiveLabel ? ACTIVE_LABEL_SCALE : INACTIVE_LABEL_SCALE); const labelTranslateY = useSharedValue(initialActiveLabel ? ACTIVE_LABEL_TRANSLATE_Y : INACTIVE_LABEL_TRANSLATE_Y); @@ -377,244 +380,255 @@ function BaseTextInput({ style={[containerStyles]} {...(shouldInterceptSwipe && SwipeInterceptPanResponder.panHandlers)} > - - - {hasLabel ? ( - <> - {/* Adding this background to the label only for multiline text input, - to prevent text overlapping with label when scrolling */} - {isMultiline && ( - - )} - - - ) : null} - {!!iconLeft && ( - - + {/* Adding this background to the label only for multiline text input, + to prevent text overlapping with label when scrolling */} + {isMultiline && ( + + )} + - - )} - {!!prefixCharacter && ( - - { - if (event.nativeEvent.layout.width === 0 && event.nativeEvent.layout.height === 0) { - return; - } - setPrefixCharacterPadding(event?.nativeEvent?.layout.width); - setIsPrefixCharacterPaddingCalculated(true); - }} - tabIndex={-1} - style={[ - styles.textInputPrefix, - !hasLabel && styles.pv0, - styles.pointerEventsNone, - inputProps.disabled && shouldUseDisabledStyles && styles.textSupporting, - prefixStyle, - ]} - dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true}} - shouldUseDefaultLineHeight={shouldUseDefaultLineHeightForPrefix} - > - {prefixCharacter} - - - )} - { - const baseTextInputRef = element as BaseTextInputRef | null; - if (typeof ref === 'function') { - ref(baseTextInputRef); - } else if (ref && 'current' in ref) { - // eslint-disable-next-line no-param-reassign - ref.current = baseTextInputRef; - } - - input.current = element as HTMLInputElement | null; - }} - {...inputProps} - // Filter out role="presentation" so it doesn't strip the native - // semantics of the . Other roles (e.g. searchbox) are preserved. - role={role === CONST.ROLE.PRESENTATION ? undefined : role} - autoCorrect={inputProps.secureTextEntry ? false : autoCorrect} - placeholder={newPlaceholder} - placeholderTextColor={placeholderTextColor ?? theme.placeholderText} - underlineColorAndroid="transparent" + + ) : null} + - {!!suffixCharacter && ( - - - {suffixCharacter} - - - )} - {((isFocused && !isReadOnly && shouldShowClearButton) || !shouldHideClearButton) && !!value && !inputProps.isLoading && ( - { - if (didScrollToEndRef.current || !input.current) { - return; + > + {!!iconLeft && ( + + + + )} + {!!prefixCharacter && ( + + { + if (event.nativeEvent.layout.width === 0 && event.nativeEvent.layout.height === 0) { + return; + } + setPrefixCharacterPadding(event?.nativeEvent?.layout.width); + setIsPrefixCharacterPaddingCalculated(true); + }} + tabIndex={-1} + style={[ + styles.textInputPrefix, + !hasLabel && styles.pv0, + styles.pointerEventsNone, + inputProps.disabled && shouldUseDisabledStyles && styles.textSupporting, + prefixStyle, + ]} + dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true}} + shouldUseDefaultLineHeight={shouldUseDefaultLineHeightForPrefix} + > + {prefixCharacter} + + + )} + { + const baseTextInputRef = element as BaseTextInputRef | null; + if (typeof ref === 'function') { + ref(baseTextInputRef); + } else if (ref && 'current' in ref) { + // eslint-disable-next-line no-param-reassign + ref.current = baseTextInputRef; } - scrollToRight(input.current); - didScrollToEndRef.current = true; - }} - > - { - setValue(''); - onClearInput?.(); - }} - iconSize={clearButtonIconSize} - style={[StyleUtils.getTextInputIconContainerStyles(hasLabel, false, verticalPaddingDiff), clearButtonStyle]} - sentryLabel={sentryLabel ? `${sentryLabel}-ClearButton` : undefined} - /> - - )} - {!!inputProps.isLoading && !shouldShowClearButton && ( - - )} - {/* Render rightHandSideComponent only when clear button is not shown - This prevents UI conflicts between clear button and custom components like flip/currency buttons */} - {!shouldShowClearButton && shouldHideClearButton && !inputProps.isLoading && !!rightHandSideComponent && ( - {rightHandSideComponent} - )} - {!!inputProps.secureTextEntry && ( - { - e.preventDefault(); + + input.current = element as HTMLInputElement | null; }} - accessibilityLabel={translate('common.visible')} - > - - - )} - {!inputProps.secureTextEntry && !!icon && ( - . Other roles (e.g. searchbox) are preserved. + role={role === CONST.ROLE.PRESENTATION ? undefined : role} + autoCorrect={inputProps.secureTextEntry ? false : autoCorrect} + placeholder={newPlaceholder} + placeholderTextColor={placeholderTextColor ?? theme.placeholderText} + underlineColorAndroid="transparent" style={[ - StyleUtils.getTextInputIconContainerStyles(hasLabel, includeIconPadding, verticalPaddingDiff), - !isReadOnly ? styles.cursorPointer : styles.pointerEventsNone, - iconContainerStyle, + styles.flex1, + styles.w100, + inputStyle, + (!hasLabel || isMultiline) && styles.pv0, + inputPaddingLeft, + inputPaddingRight, + inputProps.secureTextEntry && styles.secureInput, + + // Explicitly change boxSizing attribute for mobile chrome in order to apply line-height + // for the issue mentioned here https://github.com/Expensify/App/issues/26735 + // Set overflow property to enable the parent flexbox to shrink its size + // (See https://github.com/Expensify/App/issues/41766) + !isMultiline && isMobileChrome() && {boxSizing: 'content-box', height: undefined, ...styles.overflowAuto}, + + // Stop scrollbar flashing when breaking lines with autoGrowHeight enabled. + ...(autoGrowHeight && !isAutoGrowHeightMarkdown + ? [ + StyleUtils.getAutoGrowHeightInputStyle( + textInputHeight, + typeof maxAutoGrowHeight === 'number' ? maxAutoGrowHeight : 0, + autoGrowVerticalInset, + ), + styles.verticalAlignTop, + ] + : []), + isAutoGrowHeightMarkdown ? [StyleUtils.getMarkdownMaxHeight(maxAutoGrowHeight), styles.verticalAlignTop] : undefined, + // Add disabled color theme when field is not editable. + inputProps.disabled && shouldUseDisabledStyles && styles.textInputDisabled, + styles.pointerEventsAuto, ]} - > - + {!!suffixCharacter && ( + + + {suffixCharacter} + + + )} + {((isFocused && !isReadOnly && shouldShowClearButton) || !shouldHideClearButton) && !!value && !inputProps.isLoading && ( + { + if (didScrollToEndRef.current || !input.current) { + return; + } + scrollToRight(input.current); + didScrollToEndRef.current = true; + }} + > + { + setValue(''); + onClearInput?.(); + }} + iconSize={clearButtonIconSize} + style={[StyleUtils.getTextInputIconContainerStyles(hasLabel, false, verticalPaddingDiff), clearButtonStyle]} + sentryLabel={sentryLabel ? `${sentryLabel}-ClearButton` : undefined} + /> + + )} + {!!inputProps.isLoading && !shouldShowClearButton && ( + - - )} + )} + {/* Render rightHandSideComponent only when clear button is not shown + This prevents UI conflicts between clear button and custom components like flip/currency buttons */} + {!shouldShowClearButton && shouldHideClearButton && !inputProps.isLoading && !!rightHandSideComponent && ( + {rightHandSideComponent} + )} + {!!inputProps.secureTextEntry && ( + { + e.preventDefault(); + }} + accessibilityLabel={translate('common.visible')} + > + + + )} + {!inputProps.secureTextEntry && !!icon && ( + + + + )} + - - + + {!!inputHelpText && ( | HTMLFormElement>(null); @@ -25,12 +28,33 @@ function TextInputLabel({for: inputId = '', label, labelTranslateY, labelScale, // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + const checkTruncation = () => { + const labelElement = labelRef.current; + if (!onTruncationChange || !labelElement || !('scrollWidth' in labelElement)) { + return; + } + onTruncationChange(labelElement.scrollWidth > labelElement.offsetWidth || labelElement.scrollHeight > labelElement.offsetHeight); + }; + + // The label keeps its width while its font size animates, so `onLayout` doesn't fire when the label shrinks to fit or + // grows past its width. Check again once the animation settles on either size. + useAnimatedReaction( + () => labelScale.get(), + (scale, previousScale) => { + if (scale === previousScale || (scale !== ACTIVE_LABEL_SCALE && scale !== INACTIVE_LABEL_SCALE)) { + return; + } + scheduleOnRN(checkTruncation); + }, + ); + const animatedStyle = useAnimatedStyle(() => styles.textInputLabelTransformation(labelTranslateY, labelScale)); return ( void; }; export default TextInputLabelProps; diff --git a/src/components/Tooltip/GenericTooltip.tsx b/src/components/Tooltip/GenericTooltip.tsx index 30b8ec359380..5107d8dd681a 100644 --- a/src/components/Tooltip/GenericTooltip.tsx +++ b/src/components/Tooltip/GenericTooltip.tsx @@ -165,14 +165,13 @@ function GenericTooltip({ hideTooltip(); }; - // Skip the tooltip and return the children if the text is empty, we don't have a render function. - if (StringUtils.isEmptyString(text) && renderTooltipContent == null) { - return children({isVisible, showTooltip, hideTooltip, updateTargetBounds}); - } + // Skip the tooltip if the text is empty and we don't have a render function. The children stay in the same place in the + // tree either way, so toggling `text` doesn't remount them (which would, for example, drop an input's focus). + const hasTooltipContent = !StringUtils.isEmptyString(text) || renderTooltipContent != null; return ( <> - {shouldRender && isRendered && ( + {hasTooltipContent && shouldRender && isRendered && ( + jest.fn(() => ({ + preferredLocale: 'en', + translate: (key: string) => key, + })), +); + +function MountCounter({onMount}: {onMount: () => void}) { + useEffect(() => { + onMount(); + }, [onMount]); + return ; +} + +describe('GenericTooltip', () => { + it('does not remount its children when the text toggles between empty and non-empty', () => { + // Given a tooltip with no text, wrapping a child that counts its mounts. Callers such as a text input toggle `text` + // to turn the tooltip on and off, and a remount would drop the input's focus. + const onMount = jest.fn(); + const renderTooltip = (text: string) => {() => }; + const {rerender} = render(renderTooltip('')); + + // When the text is set and then cleared again + rerender(renderTooltip('Full report field name')); + rerender(renderTooltip('')); + + // Then the child is only mounted once + expect(onMount).toHaveBeenCalledTimes(1); + }); +}); From f541510a3d6b0d355a45642434a9d6833765aef1 Mon Sep 17 00:00:00 2001 From: "huult (via MelvinBot)" Date: Thu, 29 Oct 2026 03:00:00 +0000 Subject: [PATCH 2/4] Only measure label truncation for inputs that ask for it Move the label scale reaction into a child component that renders only when onTruncationChange is set, and skip the onLayout handler otherwise, so other text inputs don't register a worklet or measure their label. Co-authored-by: huult --- .../LabelScaleSettleListener.tsx | 36 ++++++++++++ .../TextInput/TextInputLabel/index.tsx | 58 +++++++++---------- tests/ui/components/TextInputLabel.tsx | 38 ++++++++++++ 3 files changed, 102 insertions(+), 30 deletions(-) create mode 100644 src/components/TextInput/TextInputLabel/LabelScaleSettleListener.tsx diff --git a/src/components/TextInput/TextInputLabel/LabelScaleSettleListener.tsx b/src/components/TextInput/TextInputLabel/LabelScaleSettleListener.tsx new file mode 100644 index 000000000000..7c536cbb9d1c --- /dev/null +++ b/src/components/TextInput/TextInputLabel/LabelScaleSettleListener.tsx @@ -0,0 +1,36 @@ +import {ACTIVE_LABEL_SCALE, INACTIVE_LABEL_SCALE} from '@components/TextInput/styleConst'; + +import type {SharedValue} from 'react-native-reanimated'; + +import {useAnimatedReaction} from 'react-native-reanimated'; +import {scheduleOnRN} from 'react-native-worklets'; + +type LabelScaleSettleListenerProps = { + /** Scale of the input label, animated between the inactive and active sizes */ + labelScale: SharedValue; + + /** Called on the JS thread once the label scale settles on either size */ + onSettle: () => void; +}; + +/** + * Calls `onSettle` once the label's scale animation settles. It is a separate component so that only labels that need it + * register the reaction, instead of every text input. + */ +function LabelScaleSettleListener({labelScale, onSettle}: LabelScaleSettleListenerProps) { + useAnimatedReaction( + () => labelScale.get(), + (scale, previousScale) => { + if (scale === previousScale || (scale !== ACTIVE_LABEL_SCALE && scale !== INACTIVE_LABEL_SCALE)) { + return; + } + scheduleOnRN(onSettle); + }, + ); + + return null; +} + +LabelScaleSettleListener.displayName = 'LabelScaleSettleListener'; + +export default LabelScaleSettleListener; diff --git a/src/components/TextInput/TextInputLabel/index.tsx b/src/components/TextInput/TextInputLabel/index.tsx index 11d51e608da2..d9ba2ebe9969 100644 --- a/src/components/TextInput/TextInputLabel/index.tsx +++ b/src/components/TextInput/TextInputLabel/index.tsx @@ -1,5 +1,3 @@ -import {ACTIVE_LABEL_SCALE, INACTIVE_LABEL_SCALE} from '@components/TextInput/styleConst'; - import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; @@ -10,11 +8,12 @@ import type {ComponentRef} from 'react'; import type {Text} from 'react-native'; import React, {useEffect, useRef} from 'react'; -import Animated, {useAnimatedReaction, useAnimatedStyle} from 'react-native-reanimated'; -import {scheduleOnRN} from 'react-native-worklets'; +import Animated, {useAnimatedStyle} from 'react-native-reanimated'; import type TextInputLabelProps from './types'; +import LabelScaleSettleListener from './LabelScaleSettleListener'; + function TextInputLabel({for: inputId = '', label, labelTranslateY, labelScale, isMultiline, shouldLabelStayOnSingleLine, onTruncationChange}: TextInputLabelProps) { const shouldClipToSingleLine = !isMultiline || shouldLabelStayOnSingleLine; const styles = useThemeStyles(); @@ -36,35 +35,34 @@ function TextInputLabel({for: inputId = '', label, labelTranslateY, labelScale, onTruncationChange(labelElement.scrollWidth > labelElement.offsetWidth || labelElement.scrollHeight > labelElement.offsetHeight); }; - // The label keeps its width while its font size animates, so `onLayout` doesn't fire when the label shrinks to fit or - // grows past its width. Check again once the animation settles on either size. - useAnimatedReaction( - () => labelScale.get(), - (scale, previousScale) => { - if (scale === previousScale || (scale !== ACTIVE_LABEL_SCALE && scale !== INACTIVE_LABEL_SCALE)) { - return; - } - scheduleOnRN(checkTruncation); - }, - ); - const animatedStyle = useAnimatedStyle(() => styles.textInputLabelTransformation(labelTranslateY, labelScale)); return ( - - {label} - + <> + {/* The label keeps its width while its font size animates, so `onLayout` doesn't fire when the label shrinks to fit or + grows past its width. Check again once the animation settles on either size. Only labels that report truncation + listen, so other text inputs don't pay for the reaction. */} + {!!onTruncationChange && ( + + )} + + {label} + + ); } diff --git a/tests/ui/components/TextInputLabel.tsx b/tests/ui/components/TextInputLabel.tsx index 39effe5d63eb..5010359fce0c 100644 --- a/tests/ui/components/TextInputLabel.tsx +++ b/tests/ui/components/TextInputLabel.tsx @@ -1,6 +1,7 @@ import {render, screen} from '@testing-library/react-native'; import TextInputLabel from '@components/TextInput/TextInputLabel'; +import LabelScaleSettleListener from '@components/TextInput/TextInputLabel/LabelScaleSettleListener'; import type TextInputLabelProps from '@components/TextInput/TextInputLabel/types'; import {useSharedValue} from 'react-native-reanimated'; @@ -49,3 +50,40 @@ describe('TextInputLabel', () => { expect(labelElement.props).not.toHaveProperty('ellipsizeMode'); }); }); + +describe('TextInputLabel (web)', () => { + // Jest resolves the native label by default, but truncation is only measured on web + const WebTextInputLabel = jest.requireActual<{default: typeof TextInputLabel}>('../../../src/components/TextInput/TextInputLabel/index.tsx').default; + + const labelTranslateY = useSharedValue(0); + const labelScale = useSharedValue(1); + + it('should only measure truncation when onTruncationChange is passed', () => { + // Given a label without a truncation callback, which is how most text inputs render it + render( + , + ); + + // Then it neither measures itself on layout nor listens to the label scale animation + expect(screen.getByText('Label without callback', {includeHiddenElements: true}).props.onLayout).toBeUndefined(); + expect(screen.UNSAFE_queryByType(LabelScaleSettleListener)).toBeNull(); + + // Given a label that reports truncation, like a report field name + render( + , + ); + + // Then it measures itself on layout and again once the label scale animation settles + expect(screen.getByText('Label with callback', {includeHiddenElements: true}).props.onLayout).toEqual(expect.any(Function)); + expect(screen.UNSAFE_queryByType(LabelScaleSettleListener)).not.toBeNull(); + }); +}); From f18c1987e3087a469c2bed8fcb559e62b8071e1f Mon Sep 17 00:00:00 2001 From: "huult (via MelvinBot)" Date: Thu, 29 Oct 2026 03:06:24 +0000 Subject: [PATCH 3/4] Test that an empty-text tooltip renders no bubble and keeps its children mounted Co-authored-by: huult --- tests/unit/GenericTooltipTest.tsx | 44 +++++++++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/tests/unit/GenericTooltipTest.tsx b/tests/unit/GenericTooltipTest.tsx index 34b988297f60..8a5baf98c65e 100644 --- a/tests/unit/GenericTooltipTest.tsx +++ b/tests/unit/GenericTooltipTest.tsx @@ -1,6 +1,7 @@ -import {render} from '@testing-library/react-native'; +import {act, render, screen} from '@testing-library/react-native'; import GenericTooltip from '@components/Tooltip/GenericTooltip'; +import type {GenericTooltipState} from '@components/Tooltip/types'; import React, {useEffect} from 'react'; import {View} from 'react-native'; @@ -12,11 +13,20 @@ jest.mock('@hooks/useLocalize', () => })), ); +jest.mock('@components/Tooltip/BaseGenericTooltip', () => { + const mockReact = jest.requireActual('react'); + const {View: MockView} = jest.requireActual<{View: typeof View}>('react-native'); + return { + __esModule: true, + default: () => mockReact.createElement(MockView, {testID: 'tooltip-bubble'}), + }; +}); + function MountCounter({onMount}: {onMount: () => void}) { useEffect(() => { onMount(); }, [onMount]); - return ; + return ; } describe('GenericTooltip', () => { @@ -34,4 +44,34 @@ describe('GenericTooltip', () => { // Then the child is only mounted once expect(onMount).toHaveBeenCalledTimes(1); }); + + it('renders only its children when the text is empty, and shows the bubble without remounting them once the text is set', () => { + // Given a tooltip with no text that is hovered. The children still receive `showTooltip`, so a hover can happen + // while there is nothing to show. + const onMount = jest.fn(); + let tooltipState: GenericTooltipState | undefined; + const renderTooltip = (text: string) => ( + + {(state) => { + tooltipState = state; + return ; + }} + + ); + const {rerender} = render(renderTooltip('')); + act(() => { + tooltipState?.showTooltip(); + }); + + // Then the children render but no tooltip bubble does, because there is no content + expect(screen.getByTestId('tooltip-child')).toBeOnTheScreen(); + expect(screen.queryByTestId('tooltip-bubble')).not.toBeOnTheScreen(); + + // When the text is set while the tooltip is still hovered + rerender(renderTooltip('Full report field name')); + + // Then the bubble renders and the child was not remounted + expect(screen.getByTestId('tooltip-bubble')).toBeOnTheScreen(); + expect(onMount).toHaveBeenCalledTimes(1); + }); }); From ecefa873e155e2ec06aee7c59ae7e34f0abf926e Mon Sep 17 00:00:00 2001 From: "huult (via MelvinBot)" Date: Thu, 29 Oct 2026 03:19:51 +0000 Subject: [PATCH 4/4] Measure report field label truncation again when the label text changes The input sets the label's width, so new text keeps the same box and onLayout doesn't fire. Check again on label change, so a language switch or a renamed report field updates the tooltip. Co-authored-by: huult --- .../TextInput/TextInputLabel/index.tsx | 6 +++ tests/ui/components/TextInputLabel.tsx | 40 +++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/src/components/TextInput/TextInputLabel/index.tsx b/src/components/TextInput/TextInputLabel/index.tsx index d9ba2ebe9969..8514d3db5a83 100644 --- a/src/components/TextInput/TextInputLabel/index.tsx +++ b/src/components/TextInput/TextInputLabel/index.tsx @@ -35,6 +35,12 @@ function TextInputLabel({for: inputId = '', label, labelTranslateY, labelScale, onTruncationChange(labelElement.scrollWidth > labelElement.offsetWidth || labelElement.scrollHeight > labelElement.offsetHeight); }; + // The input sets the label's width, so new text (a language switch, or a renamed report field) keeps the same box and + // `onLayout` doesn't fire. Check again whenever the text changes. + useEffect(() => { + checkTruncation(); + }, [label, checkTruncation]); + const animatedStyle = useAnimatedStyle(() => styles.textInputLabelTransformation(labelTranslateY, labelScale)); return ( diff --git a/tests/ui/components/TextInputLabel.tsx b/tests/ui/components/TextInputLabel.tsx index 5010359fce0c..f55d160a3b15 100644 --- a/tests/ui/components/TextInputLabel.tsx +++ b/tests/ui/components/TextInputLabel.tsx @@ -86,4 +86,44 @@ describe('TextInputLabel (web)', () => { expect(screen.getByText('Label with callback', {includeHiddenElements: true}).props.onLayout).toEqual(expect.any(Function)); expect(screen.UNSAFE_queryByType(LabelScaleSettleListener)).not.toBeNull(); }); + + it('should measure truncation again when the label text changes in the same box', () => { + const shortLabel = 'Short label'; + const longLabel = 'A much longer label that no longer fits in the box'; + const onTruncationChange = jest.fn(); + const renderWithLabel = (label: string) => ( + + ); + const {rerender} = render(renderWithLabel(shortLabel)); + + // Given the label measures like a web element in a 200px box, where each character takes 10px, so only labels of up to + // 20 characters fit. Jest renders a mocked Text class, which is what the label's ref holds. + let currentLabel = shortLabel; + Object.defineProperties(screen.getByText(shortLabel, {includeHiddenElements: true}).parent?.instance, { + scrollWidth: {get: () => currentLabel.length * 10}, + offsetWidth: {value: 200}, + scrollHeight: {value: 20}, + offsetHeight: {value: 20}, + }); + onTruncationChange.mockClear(); + + // When the text changes to one that no longer fits, like after a language switch or a report field rename, without a new layout + currentLabel = longLabel; + rerender(renderWithLabel(longLabel)); + + // Then the label reports that it is cut off, so the tooltip shows + expect(onTruncationChange).toHaveBeenLastCalledWith(true); + + // When the text changes back to one that fits + currentLabel = shortLabel; + rerender(renderWithLabel(shortLabel)); + + // Then the label reports that it is no longer cut off, so the tooltip hides + expect(onTruncationChange).toHaveBeenLastCalledWith(false); + }); });