Skip to content
Merged
2 changes: 2 additions & 0 deletions src/components/DatePicker/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ function DatePicker({
icon,
shouldEnableMonthYearBackdropInNarrowPane = false,
textInputContainerStyles,
shouldShowLabelTooltipWhenTruncated,
}: DateInputWithPickerProps) {
const icons = useMemoizedLazyExpensifyIcons(['Calendar']);
const styles = useThemeStyles();
Expand Down Expand Up @@ -261,6 +262,7 @@ function DatePicker({
autoComplete={autoComplete}
disableKeyboard
rightHandSideComponent={rightHandSideComponent}
shouldShowLabelTooltipWhenTruncated={shouldShowLabelTooltipWhenTruncated}
/>
</View>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
/>,
);
}
Expand Down Expand Up @@ -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
/>
</View>
);
Expand All @@ -298,6 +300,7 @@ function ReportFieldInlineInput({reportField, fieldKey, value, isDisabled, error
onSubmitEditing={() => textInputRef.current?.blur()}
ref={textInputRef}
textInputContainerStyles={textInputContainerStyles}
shouldShowLabelTooltipWhenTruncated
/>,
);
}
Expand Down
456 changes: 235 additions & 221 deletions src/components/TextInput/BaseTextInput/implementation/index.tsx

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions src/components/TextInput/BaseTextInput/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,9 @@ type CustomBaseTextInputProps = ForwardedFSClassProps &
/** Force the floating label to render on a single line and ellipsize even when the input is multiline */
shouldLabelStayOnSingleLine?: boolean;

/** Show the full label in a tooltip on hover when it is cut off. Web only, since there is no hover on native. */
shouldShowLabelTooltipWhenTruncated?: boolean;

/** Set the default value to the input if there is a valid saved value */
shouldUseDefaultValue?: boolean;

Expand Down
Original file line number Diff line number Diff line change
@@ -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<number>;

/** 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;
56 changes: 42 additions & 14 deletions src/components/TextInput/TextInputLabel/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<ComponentRef<typeof Text> | HTMLFormElement>(null);
Expand All @@ -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 (
<Animated.Text
numberOfLines={shouldClipToSingleLine ? 1 : undefined}
ellipsizeMode={shouldClipToSingleLine ? 'tail' : undefined}
ref={textRef(labelRef)}
role={CONST.ROLE.PRESENTATION}
accessible={false}
accessibilityElementsHidden
importantForAccessibility="no"
aria-hidden
style={[styles.textInputLabelContainer, styles.textInputLabel, animatedStyle, styles.pointerEventsNone]}
>
{label}
</Animated.Text>
<>
{/* 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 && (
<LabelScaleSettleListener
labelScale={labelScale}
onSettle={checkTruncation}
/>
)}
<Animated.Text
numberOfLines={shouldClipToSingleLine ? 1 : undefined}
ellipsizeMode={shouldClipToSingleLine ? 'tail' : undefined}
onLayout={onTruncationChange ? checkTruncation : undefined}
ref={textRef(labelRef)}
role={CONST.ROLE.PRESENTATION}
accessible={false}
accessibilityElementsHidden
importantForAccessibility="no"
aria-hidden
style={[styles.textInputLabelContainer, styles.textInputLabel, animatedStyle, styles.pointerEventsNone]}
>
{label}
</Animated.Text>
</>
);
}

Expand Down
3 changes: 3 additions & 0 deletions src/components/TextInput/TextInputLabel/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
9 changes: 4 additions & 5 deletions src/components/Tooltip/GenericTooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 && (
<BaseGenericTooltip
isEducationTooltip={isEducationTooltip}
animation={animation}
Expand Down
78 changes: 78 additions & 0 deletions tests/ui/components/TextInputLabel.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -49,3 +50,80 @@ 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(
<WebTextInputLabel
label="Label without callback"
labelTranslateY={labelTranslateY}
labelScale={labelScale}
/>,
);

// 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(
<WebTextInputLabel
label="Label with callback"
labelTranslateY={labelTranslateY}
labelScale={labelScale}
onTruncationChange={jest.fn()}
/>,
);

// 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) => (
<WebTextInputLabel
label={label}
labelTranslateY={labelTranslateY}
labelScale={labelScale}
onTruncationChange={onTruncationChange}
/>
);
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);
});
});
82 changes: 72 additions & 10 deletions tests/unit/GenericTooltipTest.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
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';
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<number> | undefined;

Expand All @@ -21,13 +22,17 @@ jest.mock('react-native-reanimated', () => {
};
});

jest.mock('@components/Tooltip/BaseGenericTooltip', () => ({
__esModule: true,
default: ({animation}: {animation: Reanimated.SharedValue<number>}) => {
mockAnimation = animation;
return null;
},
}));
jest.mock('@components/Tooltip/BaseGenericTooltip', () => {
const mockReact = jest.requireActual<typeof React>('react');
const {View: MockView} = jest.requireActual<{View: typeof View}>('react-native');
return {
__esModule: true,
default: ({animation}: {animation: Reanimated.SharedValue<number>}) => {
mockAnimation = animation;
return mockReact.createElement(MockView, {testID: 'tooltip-bubble'});
},
};
});

jest.mock('@components/Tooltip/TooltipSense', () => ({
__esModule: true,
Expand All @@ -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 <View testID="tooltip-child" />;
}

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) => <GenericTooltip text={text}>{() => <MountCounter onMount={onMount} />}</GenericTooltip>;
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) => (
<GenericTooltip text={text}>
{(state) => {
tooltipState = state;
return <MountCounter onMount={onMount} />;
}}
</GenericTooltip>
);
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;
Expand Down
Loading