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 && ( ; + + /** 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 c1e146dd051e..8514d3db5a83 100644 --- a/src/components/TextInput/TextInputLabel/index.tsx +++ b/src/components/TextInput/TextInputLabel/index.tsx @@ -12,7 +12,9 @@ import Animated, {useAnimatedStyle} from 'react-native-reanimated'; import type TextInputLabelProps from './types'; -function TextInputLabel({for: inputId = '', label, labelTranslateY, labelScale, isMultiline, shouldLabelStayOnSingleLine}: TextInputLabelProps) { +import LabelScaleSettleListener from './LabelScaleSettleListener'; + +function TextInputLabel({for: inputId = '', label, labelTranslateY, labelScale, isMultiline, shouldLabelStayOnSingleLine, onTruncationChange}: TextInputLabelProps) { const shouldClipToSingleLine = !isMultiline || shouldLabelStayOnSingleLine; const styles = useThemeStyles(); const labelRef = useRef | HTMLFormElement>(null); @@ -25,22 +27,48 @@ 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 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 ( - - {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/src/components/TextInput/TextInputLabel/types.ts b/src/components/TextInput/TextInputLabel/types.ts index 1330ea3d1d58..dee048f3a13c 100644 --- a/src/components/TextInput/TextInputLabel/types.ts +++ b/src/components/TextInput/TextInputLabel/types.ts @@ -14,6 +14,9 @@ type TextInputLabelProps = { /** For attribute for label */ for?: string; + + /** Called with whether the label is cut off, whenever that may have changed. Web only, since there is no hover on native. */ + onTruncationChange?: (isTruncated: boolean) => void; }; export default TextInputLabelProps; diff --git a/src/components/Tooltip/GenericTooltip.tsx b/src/components/Tooltip/GenericTooltip.tsx index e7bdbb1cb7a7..39e1b44f11f8 100644 --- a/src/components/Tooltip/GenericTooltip.tsx +++ b/src/components/Tooltip/GenericTooltip.tsx @@ -184,14 +184,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 && ( { 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(); + }); + + 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); + }); +}); diff --git a/tests/unit/GenericTooltipTest.tsx b/tests/unit/GenericTooltipTest.tsx index ff58847bec8e..fff020a4f734 100644 --- a/tests/unit/GenericTooltipTest.tsx +++ b/tests/unit/GenericTooltipTest.tsx @@ -1,4 +1,4 @@ -import {act, render} from '@testing-library/react-native'; +import {act, render, screen} from '@testing-library/react-native'; import GenericTooltip from '@components/Tooltip/GenericTooltip'; import TooltipSense from '@components/Tooltip/TooltipSense'; @@ -6,7 +6,8 @@ import type {GenericTooltipState} from '@components/Tooltip/types'; import type * as Reanimated from 'react-native-reanimated'; -import React from 'react'; +import React, {useEffect} from 'react'; +import {View} from 'react-native'; let mockAnimation: Reanimated.SharedValue | undefined; @@ -21,13 +22,17 @@ jest.mock('react-native-reanimated', () => { }; }); -jest.mock('@components/Tooltip/BaseGenericTooltip', () => ({ - __esModule: true, - default: ({animation}: {animation: Reanimated.SharedValue}) => { - mockAnimation = animation; - return null; - }, -})); +jest.mock('@components/Tooltip/BaseGenericTooltip', () => { + const mockReact = jest.requireActual('react'); + const {View: MockView} = jest.requireActual<{View: typeof View}>('react-native'); + return { + __esModule: true, + default: ({animation}: {animation: Reanimated.SharedValue}) => { + mockAnimation = animation; + return mockReact.createElement(MockView, {testID: 'tooltip-bubble'}); + }, + }; +}); jest.mock('@components/Tooltip/TooltipSense', () => ({ __esModule: true, @@ -40,9 +45,66 @@ jest.mock('@components/Tooltip/TooltipSense', () => ({ jest.mock('@libs/Log', () => ({warn: jest.fn()})); -jest.mock('@hooks/useLocalize', () => jest.fn(() => ({preferredLocale: 'en'}))); +jest.mock('@hooks/useLocalize', () => + 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); + }); + + 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); + }); + it('defers the instant TooltipSense show until the visible state commits', () => { // Given a tooltip that was shown once and hidden again, while TooltipSense is active because another tooltip was just hovered let tooltipState: GenericTooltipState | undefined;