Skip to content

Commit dc5d8e3

Browse files
authored
Merge pull request Expensify#90459 from callstack-internal/refactor/option-row-final-wiring
[No QA] refactor: OptionRowLHN — final wiring + ArchivedOptionRowLHN variant
2 parents 30cb7dc + fc685b2 commit dc5d8e3

16 files changed

Lines changed: 412 additions & 228 deletions

src/components/LHNOptionsList/OptionRowLHN/OptionRow/Avatar.tsx

Lines changed: 22 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,34 @@
11
import React from 'react';
2-
import type {ColorValue, ViewStyle} from 'react-native';
2+
import type {ColorValue} from 'react-native';
3+
import type {ValueOf} from 'type-fest';
34
import LHNAvatar from '@components/LHNOptionsList/LHNAvatar';
45
import {usePersonalDetails} from '@components/OnyxListItemProvider';
6+
import useThemeStyles from '@hooks/useThemeStyles';
57
import {shouldOptionShowTooltip} from '@libs/OptionsListUtils';
68
import {getDelegateAccountIDFromReportAction} from '@libs/ReportActionsUtils';
79
import type {OptionData} from '@libs/ReportUtils';
810
import CONST from '@src/CONST';
911

12+
type OptionMode = ValueOf<typeof CONST.OPTION_MODE>;
13+
1014
type AvatarProps = {
15+
/** Option data for the row. Source of avatar icons, subscript flag, tooltip eligibility, and delegate metadata. */
1116
optionItem: OptionData;
12-
isInFocusMode: boolean;
13-
subscriptAvatarBorderColor: ColorValue;
14-
secondaryAvatarBackgroundColor: ColorValue;
15-
singleAvatarContainerStyle: ViewStyle[];
17+
18+
/** Display density mode. `COMPACT` switches the avatar size to `SMALL`. */
19+
viewMode: OptionMode;
20+
21+
/** Background color used for both the subscript icon border and the secondary avatar background. Matches the row background so the chrome blends in. */
22+
avatarBackgroundColor: ColorValue;
1623
};
1724

18-
function AvatarInner({optionItem, isInFocusMode, subscriptAvatarBorderColor, secondaryAvatarBackgroundColor, singleAvatarContainerStyle}: AvatarProps) {
25+
function AvatarInner({optionItem, viewMode, avatarBackgroundColor}: AvatarProps) {
26+
const styles = useThemeStyles();
1927
const personalDetails = usePersonalDetails();
2028

29+
const isInFocusMode = viewMode === CONST.OPTION_MODE.COMPACT;
30+
const singleAvatarContainerStyle = [styles.actionAvatar, styles.mr3];
31+
2132
const delegateAccountID = getDelegateAccountIDFromReportAction(optionItem?.parentReportAction);
2233

2334
// Match the header's delegate avatar logic: when a delegate exists on the
@@ -53,9 +64,9 @@ function AvatarInner({optionItem, isInFocusMode, subscriptAvatarBorderColor, sec
5364
icons={icons}
5465
shouldShowSubscript={!!optionItem.shouldShowSubscript}
5566
size={isInFocusMode ? CONST.AVATAR_SIZE.SMALL : CONST.AVATAR_SIZE.DEFAULT}
56-
subscriptAvatarBorderColor={subscriptAvatarBorderColor}
67+
subscriptAvatarBorderColor={avatarBackgroundColor}
5768
useMidSubscriptSize={isInFocusMode}
58-
secondaryAvatarBackgroundColor={secondaryAvatarBackgroundColor}
69+
secondaryAvatarBackgroundColor={avatarBackgroundColor}
5970
singleAvatarContainerStyle={singleAvatarContainerStyle}
6071
shouldShowTooltip={shouldOptionShowTooltip(optionItem)}
6172
delegateAccountID={skipDelegate ? undefined : delegateAccountID}
@@ -66,18 +77,16 @@ function AvatarInner({optionItem, isInFocusMode, subscriptAvatarBorderColor, sec
6677

6778
AvatarInner.displayName = 'OptionRow.AvatarInner';
6879

69-
function Avatar({optionItem, isInFocusMode, subscriptAvatarBorderColor, secondaryAvatarBackgroundColor, singleAvatarContainerStyle}: AvatarProps) {
80+
function Avatar({optionItem, viewMode, avatarBackgroundColor}: AvatarProps) {
7081
// Bail out before subscribing to personal details when the row has no avatar to render.
7182
if (!optionItem.icons?.length || !optionItem.icons.at(0)) {
7283
return null;
7384
}
7485
return (
7586
<AvatarInner
7687
optionItem={optionItem}
77-
isInFocusMode={isInFocusMode}
78-
subscriptAvatarBorderColor={subscriptAvatarBorderColor}
79-
secondaryAvatarBackgroundColor={secondaryAvatarBackgroundColor}
80-
singleAvatarContainerStyle={singleAvatarContainerStyle}
88+
viewMode={viewMode}
89+
avatarBackgroundColor={avatarBackgroundColor}
8190
/>
8291
);
8392
}

src/components/LHNOptionsList/OptionRowLHN/OptionRow/DescriptiveText.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import FS from '@libs/Fullstory';
66
import type {OptionData} from '@libs/ReportUtils';
77

88
type DescriptiveTextProps = {
9+
/** Option data for the row. Renders `optionItem.descriptiveText` when present; component returns null otherwise. */
910
optionItem: OptionData;
1011
};
1112

src/components/LHNOptionsList/OptionRowLHN/OptionRow/DraftIndicator.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,10 @@ import useThemeStyles from '@hooks/useThemeStyles';
88
import variables from '@styles/variables';
99

1010
type DraftIndicatorProps = {
11+
/** Whether the report has an unsent draft comment. Required for the pencil icon to render. */
1112
hasDraftComment: boolean;
13+
14+
/** Whether the current user can comment in the report. The icon is hidden in read-only rows even when a draft exists. */
1215
isAllowedToComment: boolean | null | undefined;
1316
};
1417

src/components/LHNOptionsList/OptionRowLHN/OptionRow/ErrorBadge.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@ import type {OptionData} from '@libs/ReportUtils';
1111
import CONST from '@src/CONST';
1212

1313
type ErrorBadgeProps = {
14+
/** Brick road indicator for the row. The badge only renders when this equals ERROR (RBR). */
1415
brickRoadIndicator: OptionData['brickRoadIndicator'];
16+
17+
/** Action badge key used to derive the badge label (translated locally, debug builds only). */
1518
actionBadge: OptionData['actionBadge'];
1619
};
1720

src/components/LHNOptionsList/OptionRowLHN/OptionRowPressable.tsx renamed to src/components/LHNOptionsList/OptionRowLHN/OptionRow/Pressable.tsx

Lines changed: 74 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
1-
import type {ReactNode, RefObject} from 'react';
2-
import React, {useState} from 'react';
1+
import type {ReactNode} from 'react';
2+
import React, {useRef, useState} from 'react';
33
import type {GestureResponderEvent, LayoutChangeEvent, View} from 'react-native';
44
import Hoverable from '@components/Hoverable';
5+
import {useLHNTooltipContext} from '@components/LHNOptionsList/LHNTooltipContext';
6+
import useLHNRowProductTrainingTooltip from '@components/LHNOptionsList/OptionRowLHN/useLHNRowProductTrainingTooltip';
57
import PressableWithSecondaryInteraction from '@components/PressableWithSecondaryInteraction';
8+
import getContextMenuAccessibilityHint from '@components/utils/getContextMenuAccessibilityHint';
9+
import getContextMenuAccessibilityProps from '@components/utils/getContextMenuAccessibilityProps';
10+
import useEnvironment from '@hooks/useEnvironment';
11+
import useLocalize from '@hooks/useLocalize';
612
import useResponsiveLayout from '@hooks/useResponsiveLayout';
713
import useStyleUtils from '@hooks/useStyleUtils';
814
import useTheme from '@hooks/useTheme';
@@ -14,35 +20,72 @@ import {startSpan} from '@libs/telemetry/activeSpans';
1420
import {showContextMenu} from '@pages/inbox/report/ContextMenu/ReportActionContextMenu';
1521
import variables from '@styles/variables';
1622
import CONST from '@src/CONST';
17-
import useLHNRowProductTrainingTooltip from './useLHNRowProductTrainingTooltip';
1823

19-
type OptionRowPressableProps = {
24+
type PressableProps = {
25+
/** Option data for the row. Source of accessibility text and the report ID used by press/context-menu actions. */
2026
optionItem: OptionData;
27+
28+
/** Whether the row is the currently focused/active option. Drives the focused background and accessibility metadata. */
2129
isOptionFocused: boolean;
22-
isScreenFocused: boolean;
23-
popoverAnchor: RefObject<View | null>;
24-
onSelectRow: (optionItem: OptionData, popoverAnchor: RefObject<View | null>) => void;
30+
31+
/** Press handler invoked with the option data and the popover anchor ref. */
32+
onSelectRow: (optionItem: OptionData, popoverAnchor: React.RefObject<View | null>) => void;
33+
34+
/** Layout handler forwarded to the underlying pressable. */
2535
onLayout?: (event: LayoutChangeEvent) => void;
26-
accessibilityLabel: string;
27-
accessibilityHint?: string;
28-
testID?: string;
29-
children: (hovered: boolean) => ReactNode;
36+
37+
/** Fires when the mouse enters the row. Hover state lives in the parent so leaves like Avatar can react. */
38+
onHoverIn?: () => void;
39+
40+
/** Fires when the mouse leaves the row. */
41+
onHoverOut?: () => void;
42+
43+
/** Row content. */
44+
children: ReactNode;
3045
};
3146

32-
function OptionRowPressable({
33-
optionItem,
34-
isOptionFocused,
35-
isScreenFocused,
36-
popoverAnchor,
37-
onSelectRow,
38-
onLayout,
39-
accessibilityLabel,
40-
accessibilityHint,
41-
testID,
42-
children,
43-
}: OptionRowPressableProps) {
47+
function Pressable({optionItem, isOptionFocused, onSelectRow, onLayout, onHoverIn, onHoverOut, children}: PressableProps) {
48+
const theme = useTheme();
49+
const styles = useThemeStyles();
50+
const StyleUtils = useStyleUtils();
51+
const {translate} = useLocalize();
52+
const {isProduction} = useEnvironment();
53+
const {isScreenFocused} = useLHNTooltipContext();
54+
const {shouldUseNarrowLayout} = useResponsiveLayout();
4455
const {hideProductTrainingTooltip} = useLHNRowProductTrainingTooltip();
56+
57+
const popoverAnchor = useRef<View>(null);
58+
const [isContextMenuActive, setIsContextMenuActive] = useState(false);
59+
4560
const reportID = optionItem.reportID;
61+
const brickRoadIndicator = optionItem.brickRoadIndicator;
62+
const actionBadgeText = !isProduction && optionItem.actionBadge ? translate(`common.actionBadge.${optionItem.actionBadge}`) : '';
63+
64+
let accessibilityLabelForBadge = '';
65+
if (brickRoadIndicator) {
66+
accessibilityLabelForBadge = [translate('common.yourReviewIsRequired'), actionBadgeText].filter(Boolean).join(', ');
67+
} else if (optionItem.isPinned) {
68+
accessibilityLabelForBadge = translate('common.pinned');
69+
}
70+
71+
const accessibilityLabel = [
72+
`${translate('accessibilityHints.navigatesToChat')} ${optionItem.text}`,
73+
optionItem.isUnread ? translate('common.unread') : '',
74+
optionItem.alternateText ?? '',
75+
accessibilityLabelForBadge,
76+
]
77+
.filter(Boolean)
78+
.join('. ');
79+
const contextMenuHint = getContextMenuAccessibilityHint({translate});
80+
const {accessibilityLabel: accessibilityLabelWithContextMenuHint, accessibilityHint} = getContextMenuAccessibilityProps({
81+
accessibilityLabel,
82+
nativeAccessibilityHint: accessibilityLabel,
83+
contextMenuHint,
84+
});
85+
86+
// reportID may be a number contrary to the type definition
87+
const testID = typeof reportID === 'number' ? String(reportID) : reportID;
88+
4689
const onPress = (event: GestureResponderEvent | KeyboardEvent | undefined) => {
4790
hideProductTrainingTooltip();
4891
startSpan(`${CONST.TELEMETRY.SPAN_OPEN_REPORT}_${reportID}`, {
@@ -55,11 +98,6 @@ function OptionRowPressable({
5598
ReportActionComposeFocusManager.focus();
5699
onSelectRow(optionItem, popoverAnchor);
57100
};
58-
const theme = useTheme();
59-
const styles = useThemeStyles();
60-
const StyleUtils = useStyleUtils();
61-
const {shouldUseNarrowLayout} = useResponsiveLayout();
62-
const [isContextMenuActive, setIsContextMenuActive] = useState(false);
63101

64102
const showPopover = (event: MouseEvent | GestureResponderEvent) => {
65103
if (!isScreenFocused && shouldUseNarrowLayout) {
@@ -86,7 +124,10 @@ function OptionRowPressable({
86124
};
87125

88126
return (
89-
<Hoverable>
127+
<Hoverable
128+
onHoverIn={onHoverIn}
129+
onHoverOut={onHoverOut}
130+
>
90131
{(hovered) => (
91132
<PressableWithSecondaryInteraction
92133
ref={popoverAnchor}
@@ -121,19 +162,19 @@ function OptionRowPressable({
121162
(hovered || isContextMenuActive) && !isOptionFocused ? styles.sidebarLinkHover : null,
122163
]}
123164
role={CONST.ROLE.BUTTON}
124-
accessibilityLabel={accessibilityLabel}
165+
accessibilityLabel={accessibilityLabelWithContextMenuHint}
125166
accessibilityHint={accessibilityHint}
126167
onLayout={onLayout}
127168
needsOffscreenAlphaCompositing={(optionItem?.icons?.length ?? 0) >= 2}
128169
sentryLabel={CONST.SENTRY_LABEL.LHN.OPTION_ROW}
129170
>
130-
{children(hovered)}
171+
{children}
131172
</PressableWithSecondaryInteraction>
132173
)}
133174
</Hoverable>
134175
);
135176
}
136177

137-
OptionRowPressable.displayName = 'OptionRowPressable';
178+
Pressable.displayName = 'OptionRow.Pressable';
138179

139-
export default OptionRowPressable;
180+
export default Pressable;

src/components/LHNOptionsList/OptionRowLHN/OptionRow/ProductTrainingTooltip.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ type ProductTrainingTooltipProps = {
2121
};
2222

2323
type ProductTrainingTooltipInnerProps = {
24+
/** Row content the tooltip anchors to. Mounted only when the row is eligible to show a tooltip. */
2425
children: ReactElement;
2526
};
2627

src/components/LHNOptionsList/OptionRowLHN/OptionRow/Subtitle.tsx

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import React from 'react';
2-
import type {StyleProp, TextStyle} from 'react-native';
32
import type {ValueOf} from 'type-fest';
43
import Text from '@components/Text';
54
import useLocalize from '@hooks/useLocalize';
@@ -13,13 +12,17 @@ import CONST from '@src/CONST';
1312
type OptionMode = ValueOf<typeof CONST.OPTION_MODE>;
1413

1514
type SubtitleProps = {
15+
/** Option data for the row. Source of `alternateText` and emoji rendering hints. */
1616
optionItem: OptionData;
17+
18+
/** Display density mode. Compact rows render the subtitle with compact styles; the subtitle is hidden only when `optionItem.alternateText` is empty. */
1719
viewMode: OptionMode;
20+
21+
/** Whether the row is the currently focused/active option. Drives the active text style. */
1822
isOptionFocused: boolean;
19-
style?: StyleProp<TextStyle>;
2023
};
2124

22-
function Subtitle({optionItem, viewMode, isOptionFocused, style}: SubtitleProps) {
25+
function Subtitle({optionItem, viewMode, isOptionFocused}: SubtitleProps) {
2326
const {translate} = useLocalize();
2427
const styles = useThemeStyles();
2528

@@ -31,8 +34,8 @@ function Subtitle({optionItem, viewMode, isOptionFocused, style}: SubtitleProps)
3134
const isInFocusMode = viewMode === CONST.OPTION_MODE.COMPACT;
3235
const textStyle = isOptionFocused ? styles.sidebarLinkActiveText : styles.sidebarLinkText;
3336
const alternateTextStyle = isInFocusMode
34-
? [textStyle, styles.textLabelSupporting, styles.optionAlternateTextCompact, styles.ml2, style]
35-
: [textStyle, styles.optionAlternateText, styles.textLabelSupporting, style];
37+
? [textStyle, styles.textLabelSupporting, styles.optionAlternateTextCompact, styles.ml2]
38+
: [textStyle, styles.optionAlternateText, styles.textLabelSupporting];
3639
const alternateTextFSClass = FS.getChatFSClass(optionItem);
3740

3841
const containsCustomEmojiWithText = containsCustomEmojiUtils(alternateText) && !containsOnlyCustomEmoji(alternateText);

src/components/LHNOptionsList/OptionRowLHN/OptionRow/Title.tsx

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,30 @@
11
import React from 'react';
2-
import type {StyleProp, TextStyle} from 'react-native';
32
import DisplayNames from '@components/DisplayNames';
43
import useLocalize from '@hooks/useLocalize';
4+
import useThemeStyles from '@hooks/useThemeStyles';
5+
import {shouldUseBoldText} from '@libs/OptionsListUtils';
56
import type {OptionData} from '@libs/ReportUtils';
67
import {isGroupChat, isSystemChat} from '@libs/ReportUtils';
78
import CONST from '@src/CONST';
89

910
type TitleProps = {
11+
/** Option data for the row. Source of `text`, `displayNamesWithTooltips`, chat-type flags, parse-mode hints, and unread/bold derivation. */
1012
optionItem: OptionData;
11-
displayNameStyle: StyleProp<TextStyle>;
13+
14+
/** Whether the row is the currently focused/active option. Drives the active text style. */
15+
isOptionFocused: boolean;
16+
17+
/** Numeric testID for the title node. This is the FlashList row index passed in from the renderItem callback, not the report ID. */
1218
testID: number;
1319
};
1420

15-
function Title({optionItem, displayNameStyle, testID}: TitleProps) {
21+
function Title({optionItem, isOptionFocused, testID}: TitleProps) {
1622
const {translate} = useLocalize();
23+
const styles = useThemeStyles();
24+
25+
const textStyle = isOptionFocused ? styles.sidebarLinkActiveText : styles.sidebarLinkText;
26+
const textUnreadStyle = shouldUseBoldText(optionItem) ? [textStyle, styles.sidebarLinkTextBold] : [textStyle];
27+
const displayNameStyle = [styles.optionDisplayName, styles.optionDisplayNameCompact, styles.pre, textUnreadStyle, styles.flexShrink0];
1728

1829
const shouldParseFullTitle = optionItem?.parentReportAction?.actionName !== CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT && !isGroupChat(optionItem);
1930
const shouldUseFullTitle =
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import Avatar from './Avatar';
2+
import DescriptiveText from './DescriptiveText';
3+
import DraftIndicator from './DraftIndicator';
4+
import ErrorBadge from './ErrorBadge';
5+
import InfoBadge from './InfoBadge';
6+
import OfflineWrapper from './OfflineWrapper';
7+
import OnboardingBadge from './OnboardingBadge';
8+
import PinIndicator from './PinIndicator';
9+
import Pressable from './Pressable';
10+
import ProductTrainingTooltip from './ProductTrainingTooltip';
11+
import Status from './Status';
12+
import Subtitle from './Subtitle';
13+
import Title from './Title';
14+
15+
const OptionRow = {
16+
Avatar,
17+
DescriptiveText,
18+
DraftIndicator,
19+
ErrorBadge,
20+
InfoBadge,
21+
OfflineWrapper,
22+
OnboardingBadge,
23+
PinIndicator,
24+
Pressable,
25+
ProductTrainingTooltip,
26+
Status,
27+
Subtitle,
28+
Title,
29+
};
30+
31+
export default OptionRow;

0 commit comments

Comments
 (0)