From 1496e715ceac13ed927a8618622592a651b9e78e Mon Sep 17 00:00:00 2001 From: war-in Date: Mon, 17 Aug 2026 12:57:45 +0200 Subject: [PATCH 01/15] feat: add primitives to support menu item with avatars --- src/components/MenuItem/index.ts | 8 ++++++- .../MenuItem/layout/MenuItemLeading.tsx | 20 ++++++++++++++++ .../MenuItem/leaves/leading/MenuItemIcon.tsx | 20 +++++++--------- .../MenuItem/leaves/text/MenuItemLabel.tsx | 19 +++++++++++++++ .../MenuItem/leaves/text/MenuItemTitle.tsx | 24 ++++++++++++++----- .../MenuItem/presets/MenuItemAction.tsx | 5 +++- .../MenuItem/presets/MenuItemNavigation.tsx | 5 +++- src/styles/index.ts | 5 ++++ 8 files changed, 86 insertions(+), 20 deletions(-) create mode 100644 src/components/MenuItem/layout/MenuItemLeading.tsx create mode 100644 src/components/MenuItem/leaves/text/MenuItemLabel.tsx diff --git a/src/components/MenuItem/index.ts b/src/components/MenuItem/index.ts index 4c40cd6eadf0..76f9d7639302 100644 --- a/src/components/MenuItem/index.ts +++ b/src/components/MenuItem/index.ts @@ -13,7 +13,9 @@ * * * - * + * + * + * * * {translate('common.settings')} * @@ -25,11 +27,13 @@ * ``` */ import MenuItemContent from './layout/MenuItemContent'; +import MenuItemLeading from './layout/MenuItemLeading'; import MenuItemRoot from './layout/MenuItemRoot'; import MenuItemRow from './layout/MenuItemRow'; import MenuItemTrailing from './layout/MenuItemTrailing'; import MenuItemIcon from './leaves/leading/MenuItemIcon'; import MenuItemDescription from './leaves/text/MenuItemDescription'; +import MenuItemLabel from './leaves/text/MenuItemLabel'; import MenuItemTitle from './leaves/text/MenuItemTitle'; import MenuItemChevron from './leaves/trailing/MenuItemChevron'; import LegacyMenuItem from './MenuItem'; @@ -37,9 +41,11 @@ import LegacyMenuItem from './MenuItem'; const MenuItem = Object.assign(LegacyMenuItem, { Root: MenuItemRoot, Row: MenuItemRow, + Leading: MenuItemLeading, Content: MenuItemContent, Trailing: MenuItemTrailing, Icon: MenuItemIcon, + Label: MenuItemLabel, Title: MenuItemTitle, Description: MenuItemDescription, Chevron: MenuItemChevron, diff --git a/src/components/MenuItem/layout/MenuItemLeading.tsx b/src/components/MenuItem/layout/MenuItemLeading.tsx new file mode 100644 index 000000000000..b05af95dcaa8 --- /dev/null +++ b/src/components/MenuItem/layout/MenuItemLeading.tsx @@ -0,0 +1,20 @@ +import useThemeStyles from '@hooks/useThemeStyles'; + +import type {PropsWithChildren} from 'react'; + +import React from 'react'; +import {View} from 'react-native'; + +type MenuItemLeadingProps = PropsWithChildren; + +/** + * The leading cell of a `MenuItem.Row`. Sets no width of its own — it centers and sizes to its + * content, so an icon, avatar or spinner brings its own footprint. + */ +function MenuItemLeading({children}: MenuItemLeadingProps) { + const styles = useThemeStyles(); + + return {children}; +} + +export default MenuItemLeading; diff --git a/src/components/MenuItem/leaves/leading/MenuItemIcon.tsx b/src/components/MenuItem/leaves/leading/MenuItemIcon.tsx index 21185f7f6d56..60233bf746c8 100644 --- a/src/components/MenuItem/leaves/leading/MenuItemIcon.tsx +++ b/src/components/MenuItem/leaves/leading/MenuItemIcon.tsx @@ -10,14 +10,13 @@ import getButtonState from '@libs/getButtonState'; import type IconAsset from '@src/types/utils/IconAsset'; import React from 'react'; -import {View} from 'react-native'; type MenuItemIconProps = { /** Icon to display */ src: IconAsset; }; -/** The leading icon cell of a `MenuItem.Row` */ +/** An icon glyph, filled from the row's interaction state */ function MenuItemIcon({src}: MenuItemIconProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); @@ -31,15 +30,14 @@ function MenuItemIcon({src}: MenuItemIconProps) { const iconFill = StyleUtils.getIconFillColor(getButtonState(isHovered, isPressed, isComplete, isDisabled, isInteractive), isMenuIcon, isPane); return ( - - - + ); } diff --git a/src/components/MenuItem/leaves/text/MenuItemLabel.tsx b/src/components/MenuItem/leaves/text/MenuItemLabel.tsx new file mode 100644 index 000000000000..032a1516d978 --- /dev/null +++ b/src/components/MenuItem/leaves/text/MenuItemLabel.tsx @@ -0,0 +1,19 @@ +import Text from '@components/Text'; + +import useThemeStyles from '@hooks/useThemeStyles'; + +import React from 'react'; + +type MenuItemLabelProps = { + /** Text to render as the label */ + children: string; +}; + +/** The label above a menu item's `Row` */ +function MenuItemLabel({children}: MenuItemLabelProps) { + const styles = useThemeStyles(); + + return {children}; +} + +export default MenuItemLabel; diff --git a/src/components/MenuItem/leaves/text/MenuItemTitle.tsx b/src/components/MenuItem/leaves/text/MenuItemTitle.tsx index ccafead71111..c1e89d5e1c62 100644 --- a/src/components/MenuItem/leaves/text/MenuItemTitle.tsx +++ b/src/components/MenuItem/leaves/text/MenuItemTitle.tsx @@ -8,19 +8,31 @@ import convertToLTR from '@libs/convertToLTR'; import CONST from '@src/CONST'; +import type {ReactElement} from 'react'; + import React from 'react'; -type MenuItemTitleProps = { - /** Text to render as the title */ - children: string | number; -}; +type MenuItemTitleProps = + | { + /** Text to render as the title */ + children: string | number; + + accessibilityLabel?: never; + } + | { + /** Element to render in place of plain text, e.g. a `DisplayNames` with per-name tooltips */ + children: ReactElement; + + /** Plain-text form of the title, contributed to the row's accessibility label */ + accessibilityLabel: string; + }; /** The title block of a `MenuItem.Content`. Bold, single line */ -function MenuItemTitle({children}: MenuItemTitleProps) { +function MenuItemTitle({children, accessibilityLabel}: MenuItemTitleProps) { const styles = useThemeStyles(); const {isDisabled, isInteractive} = useMenuItemConfig(); - useMenuItemAccessibilityLabel('title', String(children)); + useMenuItemAccessibilityLabel('title', accessibilityLabel ?? String(children)); return ( - + + + {title} diff --git a/src/components/MenuItem/presets/MenuItemNavigation.tsx b/src/components/MenuItem/presets/MenuItemNavigation.tsx index 9a2770595b00..740ddcc6567b 100644 --- a/src/components/MenuItem/presets/MenuItemNavigation.tsx +++ b/src/components/MenuItem/presets/MenuItemNavigation.tsx @@ -1,4 +1,5 @@ import MenuItemContent from '@components/MenuItem/layout/MenuItemContent'; +import MenuItemLeading from '@components/MenuItem/layout/MenuItemLeading'; import MenuItemRoot from '@components/MenuItem/layout/MenuItemRoot'; import MenuItemRow from '@components/MenuItem/layout/MenuItemRow'; import MenuItemTrailing from '@components/MenuItem/layout/MenuItemTrailing'; @@ -42,7 +43,9 @@ function MenuItemNavigation({title, icon, onPress, isDisabled = false, sentryLab accessibilityLabel={title} > - + + + {title} diff --git a/src/styles/index.ts b/src/styles/index.ts index 850bb0126804..1f660c2b57c6 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -5352,6 +5352,11 @@ const staticStyles = (theme: ThemeColors) => ...spacing.gap3, }, + menuItemLeading: { + ...flex.justifyContentCenter, + ...flex.alignItemsCenter, + }, + menuItemTrailing: { ...flex.flexRow, ...flex.alignItemsCenter, From ff567b69904af5426fa2b4aa9784b76d9e6fb965 Mon Sep 17 00:00:00 2001 From: war-in Date: Mon, 17 Aug 2026 18:16:23 +0200 Subject: [PATCH 02/15] feat: add MenuItemEntity --- .../MenuItem/presets/MenuItemEntity.tsx | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 src/components/MenuItem/presets/MenuItemEntity.tsx diff --git a/src/components/MenuItem/presets/MenuItemEntity.tsx b/src/components/MenuItem/presets/MenuItemEntity.tsx new file mode 100644 index 000000000000..bcc9acc2f421 --- /dev/null +++ b/src/components/MenuItem/presets/MenuItemEntity.tsx @@ -0,0 +1,71 @@ +import UserAvatar from '@components/Avatar/UserAvatar'; +import MenuItemContent from '@components/MenuItem/layout/MenuItemContent'; +import MenuItemLeading from '@components/MenuItem/layout/MenuItemLeading'; +import MenuItemRoot from '@components/MenuItem/layout/MenuItemRoot'; +import MenuItemRow from '@components/MenuItem/layout/MenuItemRow'; +import MenuItemTrailing from '@components/MenuItem/layout/MenuItemTrailing'; +import MenuItemDescription from '@components/MenuItem/leaves/text/MenuItemDescription'; +import MenuItemTitle from '@components/MenuItem/leaves/text/MenuItemTitle'; +import MenuItemChevron from '@components/MenuItem/leaves/trailing/MenuItemChevron'; + +import type {AvatarSource} from '@libs/UserAvatarUtils'; + +import {callFunctionIfActionIsAllowed} from '@userActions/Session'; + +import type WithSentryLabel from '@src/types/utils/SentryLabel'; + +import type {GestureResponderEvent} from 'react-native'; + +import React from 'react'; + +type MenuItemEntityProps = WithSentryLabel & { + /** The entity's name, rendered as the row's title */ + title: string; + + /** Supporting line under the title — an email, address, or other secondary identifier */ + description: string; + + /** Account ID the avatar belongs to. Picks the default avatar when `avatarSource` is absent */ + accountID: number; + + /** The entity's avatar. Falls back to the default avatar for `accountID` when omitted */ + avatarSource?: AvatarSource; + + /** Function to fire when the row is pressed */ + onPress: (event: GestureResponderEvent | KeyboardEvent) => void | Promise; + + /** Whether the menu item is disabled */ + isDisabled?: boolean; +}; + +/** + * The entity MenuItem preset — a tappable row led by a person's avatar, with their name as the title + * and a secondary identifier below, that navigates. + */ +function MenuItemEntity({title, description, accountID, avatarSource, onPress, isDisabled = false, sentryLabel}: MenuItemEntityProps) { + return ( + + + + + + + {title} + {description} + + + + + + + ); +} + +export default MenuItemEntity; From a9664342f9cc7353f5bf898307d880f4092b2839 Mon Sep 17 00:00:00 2001 From: war-in Date: Mon, 17 Aug 2026 18:16:45 +0200 Subject: [PATCH 03/15] refactor: migrate avatar-related callsites --- src/components/DelegatorList.tsx | 30 ++++-- .../leaves/text/MenuItemDescription.tsx | 29 ++++- .../sections/InvoiceSenderField.tsx | 62 +++++++---- src/components/VacationDelegateMenuItem.tsx | 58 +++++++--- .../MultifactorAuthentication/RevokePage.tsx | 36 +++---- .../BeneficialOwnersList.tsx | 15 +-- .../subSteps/CompanyOwnersListUBO.tsx | 39 ++++--- .../ScheduleCallConfirmationPage.tsx | 29 +++-- .../step/IOURequestEditReportCommon.tsx | 37 ++++--- .../AddDelegate/ConfirmDelegatePage.tsx | 23 ++-- src/pages/tasks/DynamicNewTaskPage.tsx | 100 ++++++++++++++---- .../categories/CategorySettingsPage.tsx | 2 +- .../WorkspaceCompanyCardsSettingsPage.tsx | 17 ++- .../assignCard/ConfirmationStep.tsx | 10 +- 14 files changed, 324 insertions(+), 163 deletions(-) diff --git a/src/components/DelegatorList.tsx b/src/components/DelegatorList.tsx index 291e3ad08e3a..db6d83211a4e 100644 --- a/src/components/DelegatorList.tsx +++ b/src/components/DelegatorList.tsx @@ -6,7 +6,9 @@ import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; import React from 'react'; +import {View} from 'react-native'; +import UserAvatar from './Avatar/UserAvatar'; import MenuItem from './MenuItem'; import Text from './Text'; @@ -33,17 +35,25 @@ function DelegatorList({delegators, message}: DelegatorListProps) { const displayLogin = formattedLogin || delegatorEmail; return ( - + style={styles.mt1} + > + + + + + + + {delegatorDetails?.displayName ?? displayLogin} + {displayLogin} + + + + ); })} diff --git a/src/components/MenuItem/leaves/text/MenuItemDescription.tsx b/src/components/MenuItem/leaves/text/MenuItemDescription.tsx index 0bcea9d541a4..351afeef8b19 100644 --- a/src/components/MenuItem/leaves/text/MenuItemDescription.tsx +++ b/src/components/MenuItem/leaves/text/MenuItemDescription.tsx @@ -1,25 +1,48 @@ import {useMenuItemAccessibilityLabel} from '@components/MenuItem/MenuItemAccessibilityContext'; import Text from '@components/Text'; +import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; +import variables from '@styles/variables'; + import React from 'react'; type MenuItemDescriptionProps = { /** Text to render as the description */ children: string | number; + + /** Maximum number of lines to render before the text is truncated */ + numberOfLines?: number; + + /** + * Which text size to render at. + * + * - `supporting` (default) — the small line that sits under a `Title`. + * - `standalone` — the larger size used when the description is the row's only text, e.g. an + * unfilled field row whose description doubles as the field's placeholder. + */ + variant?: 'supporting' | 'standalone'; }; /** The supporting text block of a `MenuItem.Content` */ -function MenuItemDescription({children}: MenuItemDescriptionProps) { +function MenuItemDescription({children, numberOfLines = 2, variant = 'supporting'}: MenuItemDescriptionProps) { const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); useMenuItemAccessibilityLabel('description', String(children)); + const isStandalone = variant === 'standalone'; + return ( {children} diff --git a/src/components/MoneyRequestConfirmationList/sections/InvoiceSenderField.tsx b/src/components/MoneyRequestConfirmationList/sections/InvoiceSenderField.tsx index e009a9ec865d..6a679d62608c 100644 --- a/src/components/MoneyRequestConfirmationList/sections/InvoiceSenderField.tsx +++ b/src/components/MoneyRequestConfirmationList/sections/InvoiceSenderField.tsx @@ -1,3 +1,4 @@ +import WorkspaceAvatar from '@components/Avatar/WorkspaceAvatar'; import MenuItem from '@components/MenuItem'; import useLocalize from '@hooks/useLocalize'; @@ -18,6 +19,7 @@ import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import {emailSelector} from '@selectors/Session'; import React from 'react'; +import {View} from 'react-native'; type InvoiceSenderFieldProps = { /** The selected participants */ @@ -58,29 +60,47 @@ function InvoiceSenderField({selectedParticipants, isReadOnly, didConfirm, trans selector: createCanUpdateSenderWorkspaceSelector(isInvoiceRoomParticipant, currentUserLogin, isFromGlobalCreate), }); + const isInteractive = !isReadOnly && !!canUpdateSenderWorkspace; + return ( - { - if (!transaction?.transactionID) { - return; + <> + + {translate('workspace.invoices.sendFrom')} + + { + if (!transaction?.transactionID) { + return; + } + Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.MONEY_REQUEST_STEP_SEND_FROM.path)); + } + : undefined } - Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.MONEY_REQUEST_STEP_SEND_FROM.path)); - }} - style={styles.moneyRequestMenuItem} - labelStyle={styles.mt2} - titleStyle={styles.flex1} - disabled={didConfirm} - sentryLabel={CONST.SENTRY_LABEL.REQUEST_CONFIRMATION_LIST.SEND_FROM_FIELD} - /> + isDisabled={didConfirm} + sentryLabel={CONST.SENTRY_LABEL.REQUEST_CONFIRMATION_LIST.SEND_FROM_FIELD} + > + + + + + + {senderWorkspace?.name ?? ''} + {translate('workspace.common.workspace')} + + {isInteractive && ( + + + + )} + + + ); } diff --git a/src/components/VacationDelegateMenuItem.tsx b/src/components/VacationDelegateMenuItem.tsx index 4a3f21a4ea3b..07c102e308a0 100644 --- a/src/components/VacationDelegateMenuItem.tsx +++ b/src/components/VacationDelegateMenuItem.tsx @@ -9,6 +9,7 @@ import type {BaseVacationDelegate} from '@src/types/onyx/VacationDelegate'; import React from 'react'; +import UserAvatar from './Avatar/UserAvatar'; import MenuItem from './MenuItem'; import OfflineWithFeedback from './OfflineWithFeedback'; import Text from './Text'; @@ -46,7 +47,45 @@ function VacationDelegateMenuItem({vacationDelegate, errors, pendingAction, onCl const formattedDelegateLogin = formatPhoneNumber(vacationDelegatePersonalDetails?.login ?? ''); const fallbackVacationDelegateLogin = formattedDelegateLogin === '' ? vacationDelegate?.delegate : formattedDelegateLogin; - return hasVacationDelegate ? ( + // With a delegate set, the row shows their name with their login underneath. Without one, the field's + // own label takes the description slot as a placeholder, so it renders at the standalone size instead. + const description = hasVacationDelegate ? fallbackVacationDelegateLogin : translate('common.vacationDelegate'); + + const delegateRow = ( + + + {hasVacationDelegate && ( + + + + )} + + {hasVacationDelegate && {vacationDelegatePersonalDetails?.displayName ?? fallbackVacationDelegateLogin ?? ''}} + {!!description && ( + + {description} + + )} + + + + + + + ); + + // The section heading and the offline/error feedback only exist once a delegate is set. + if (!hasVacationDelegate) { + return delegateRow; + } + + return ( <> {translate('common.vacationDelegate')} - + {delegateRow} - ) : ( - ); } diff --git a/src/pages/MultifactorAuthentication/RevokePage.tsx b/src/pages/MultifactorAuthentication/RevokePage.tsx index 30de7ea7da43..fdbb2af0e7a6 100644 --- a/src/pages/MultifactorAuthentication/RevokePage.tsx +++ b/src/pages/MultifactorAuthentication/RevokePage.tsx @@ -188,12 +188,12 @@ function MultifactorAuthenticationRevokePage() { {/* The isCurrentDeviceRegistered guard guarantees localCredentialID is truthy here. Do not remove this guard without updating the non-null assertion on localCredentialID below. */} {isCurrentDeviceRegistered && ( - + + + + {translate('multifactorAuthentication.revoke.thisDevice')} + + - - } - /> + + + )} {otherDeviceCount > 0 && ( - + + + + {translate('multifactorAuthentication.revoke.otherDevices', otherDeviceCount)} + + - - } - /> + + + )} )} diff --git a/src/pages/ReimbursementAccount/NonUSD/BeneficialOwnerInfo/BeneficialOwnersList.tsx b/src/pages/ReimbursementAccount/NonUSD/BeneficialOwnerInfo/BeneficialOwnersList.tsx index 3122a604b4dd..416c5a712345 100644 --- a/src/pages/ReimbursementAccount/NonUSD/BeneficialOwnerInfo/BeneficialOwnersList.tsx +++ b/src/pages/ReimbursementAccount/NonUSD/BeneficialOwnerInfo/BeneficialOwnersList.tsx @@ -1,10 +1,9 @@ import Button from '@components/ButtonComposed'; import DotIndicatorMessage from '@components/DotIndicatorMessage'; -import MenuItem from '@components/MenuItem'; +import MenuItemEntity from '@components/MenuItem/presets/MenuItemEntity'; import ScrollView from '@components/ScrollView'; import Text from '@components/Text'; -import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; @@ -33,7 +32,6 @@ type BeneficialOwnersListProps = { }; function BeneficialOwnersList({handleConfirmation, ownerKeys, handleOwnerEdit}: BeneficialOwnersListProps) { - const icons = useMemoizedLazyExpensifyIcons(['FallbackAvatar']); const {translate} = useLocalize(); const styles = useThemeStyles(); const {isOffline} = useNetwork(); @@ -49,21 +47,14 @@ function BeneficialOwnersList({handleConfirmation, ownerKeys, handleOwnerEdit}: const ownerData = getValuesForBeneficialOwner(ownerKey, reimbursementAccountDraft); return ( - { handleOwnerEdit(ownerKey); }} - iconWidth={40} - iconHeight={40} - interactive - shouldShowRightIcon - displayInDefaultIconColor /> ); }); diff --git a/src/pages/ReimbursementAccount/USD/BeneficialOwnerInfo/subSteps/CompanyOwnersListUBO.tsx b/src/pages/ReimbursementAccount/USD/BeneficialOwnerInfo/subSteps/CompanyOwnersListUBO.tsx index 9e590ff3fc53..1d6276b40c5f 100644 --- a/src/pages/ReimbursementAccount/USD/BeneficialOwnerInfo/subSteps/CompanyOwnersListUBO.tsx +++ b/src/pages/ReimbursementAccount/USD/BeneficialOwnerInfo/subSteps/CompanyOwnersListUBO.tsx @@ -1,6 +1,8 @@ +import UserAvatar from '@components/Avatar/UserAvatar'; import Button from '@components/ButtonComposed'; import DotIndicatorMessage from '@components/DotIndicatorMessage'; import MenuItem from '@components/MenuItem'; +import MenuItemEntity from '@components/MenuItem/presets/MenuItemEntity'; import ScrollView from '@components/ScrollView'; import Text from '@components/Text'; @@ -63,21 +65,14 @@ function CompanyOwnersListUBO({isAnyoneElseUBO, isUserUBO, handleUBOsConfirmatio const beneficialOwnerData = getValuesForBeneficialOwner(ownerKey, reimbursementAccountDraft); return ( - { handleUBOEdit(ownerKey); }} - iconWidth={40} - iconHeight={40} - interactive - shouldShowRightIcon - displayInDefaultIconColor /> ); }); @@ -92,18 +87,20 @@ function CompanyOwnersListUBO({isAnyoneElseUBO, isUserUBO, handleUBOsConfirmatio {`${translate('beneficialOwnerInfoStep.owners')}:`} {isUserUBO && ( - + + + + + + + {`${requestorData.firstName} ${requestorData.lastName}`} + {`${requestorData.requestorAddressStreet}, ${requestorData.requestorAddressCity}, ${requestorData.requestorAddressState} ${requestorData.requestorAddressZipCode}`} + + + )} {extraBeneficialOwners} diff --git a/src/pages/ScheduleCall/ScheduleCallConfirmationPage.tsx b/src/pages/ScheduleCall/ScheduleCallConfirmationPage.tsx index 69460b72c14f..205819e75260 100644 --- a/src/pages/ScheduleCall/ScheduleCallConfirmationPage.tsx +++ b/src/pages/ScheduleCall/ScheduleCallConfirmationPage.tsx @@ -5,6 +5,7 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton'; import MenuItem from '@components/MenuItem'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import {usePersonalDetails} from '@components/OnyxListItemProvider'; +import ReportActionAvatars from '@components/ReportActionAvatars'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; import Text from '@components/Text'; @@ -31,6 +32,7 @@ import type {PersonalDetails} from '@src/types/onyx'; import {useRoute} from '@react-navigation/native'; import {addMinutes} from 'date-fns'; import React, {useEffect} from 'react'; +import {View} from 'react-native'; function ScheduleCallConfirmationPage() { const styles = useThemeStyles(); @@ -109,14 +111,25 @@ function ScheduleCallConfirmationPage() { {translate('scheduledCall.confirmation.description')} - + + + + {translate('scheduledCall.confirmation.setupSpecialist')} + + + + + + + {guideDetails?.displayName ?? ''} + {!!guideDetails?.login && {guideDetails.login}} + + + + (searchValue && !reportOptions.length ? translate('common.noResultsFound') : ''), [searchValue, reportOptions.length, translate]); + const policyForMovingExpensesName = policyForMovingExpenses?.name; const createReportOption = useMemo(() => { if (!createReport || (isEditing && !isOwner && !isAdmin)) { return undefined; } return ( - + + + + + + + {translate('report.newReport.createReport')} + {!!policyForMovingExpensesName && {policyForMovingExpensesName}} + + + ); - }, [icons.Document, createReport, translate, policyForMovingExpenses?.name, handleCreateReport, isEditing, isOwner, isAdmin]); + }, [icons.Document, createReport, translate, policyForMovingExpensesName, handleCreateReport, isEditing, isOwner, isAdmin]); const shouldShowNotFoundPage = useMemo(() => { if (createReportOption) { @@ -374,12 +380,17 @@ function IOURequestEditReportCommon({ customListHeaderContent={createReportOption} listFooterContent={ shouldShowRemoveFromReport ? ( - + + + + + + + {translate('iou.removeFromReport')} + {translate('iou.moveToPersonalSpace')} + + + ) : undefined } listEmptyContent={createReportOption} diff --git a/src/pages/settings/Security/AddDelegate/ConfirmDelegatePage.tsx b/src/pages/settings/Security/AddDelegate/ConfirmDelegatePage.tsx index f7fcb2ddbb23..b8abe7e723db 100644 --- a/src/pages/settings/Security/AddDelegate/ConfirmDelegatePage.tsx +++ b/src/pages/settings/Security/AddDelegate/ConfirmDelegatePage.tsx @@ -1,3 +1,4 @@ +import UserAvatar from '@components/Avatar/UserAvatar'; import Button from '@components/ButtonComposed'; import DelegateNoAccessWrapper from '@components/DelegateNoAccessWrapper'; import HeaderPageLayout from '@components/HeaderPageLayout'; @@ -66,14 +67,20 @@ function ConfirmDelegatePage({route}: ConfirmDelegatePageProps) { > {translate('delegate.confirmCopilot')} - + + + + + + + {displayName} + {formattedLogin} + + + - Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.NEW_TASK_ASSIGNEE.path))} - shouldShowRightIcon - titleWithTooltips={assigneeTooltipDetails} - /> - Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.NEW_TASK_SHARE_DESTINATION.path))} - interactive={!task?.parentReportID} - shouldShowRightIcon={!task?.parentReportID} - titleWithTooltips={shareDestination?.shouldUseFullTitleToDisplay ? undefined : shareDestination?.displayNamesWithTooltips} - rightLabel={translate('common.required')} - /> + Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.NEW_TASK_ASSIGNEE.path))}> + {!!assignee?.displayName && ( + + {translate('task.assignee')} + + )} + + {!!task?.assigneeAccountID && ( + + + + )} + + {!!assignee?.displayName && + (assigneeTooltipDetails.length > 0 ? ( + + + + ) : ( + {assignee.displayName} + ))} + + {assignee?.displayName ? formatPhoneNumber(assignee?.subtitle) : translate('task.assignee')} + + + + + + + + Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.NEW_TASK_SHARE_DESTINATION.path))}> + {!!shareDestination?.displayName && ( + + {translate('common.share')} + + )} + + {!!task?.shareDestination && ( + + + + )} + + {!!shareDestination?.displayName && + (!shareDestination.shouldUseFullTitleToDisplay && shareDestination.displayNamesWithTooltips.length > 0 ? ( + + + + ) : ( + {shareDestination.displayName} + ))} + + {shareDestination?.displayName ? (shareDestination.subtitle ?? '') : translate('common.share')} + + + + {!task?.shareDestination && {translate('common.required')}} + {!task?.parentReportID && } + + + diff --git a/src/pages/workspace/categories/CategorySettingsPage.tsx b/src/pages/workspace/categories/CategorySettingsPage.tsx index 00a4c1c54775..69ca4be8264b 100644 --- a/src/pages/workspace/categories/CategorySettingsPage.tsx +++ b/src/pages/workspace/categories/CategorySettingsPage.tsx @@ -579,7 +579,7 @@ function CategorySettingsPage({route: {params, name}, navigation}: CategorySetti ))} {canWriteRules && ( - navigateToCategoryRule(DYNAMIC_ROUTES.WORKSPACE_CATEGORY_RULES_NEW.path)} diff --git a/src/pages/workspace/companyCards/WorkspaceCompanyCardsSettingsPage.tsx b/src/pages/workspace/companyCards/WorkspaceCompanyCardsSettingsPage.tsx index f2c2dccb7f5c..f5fcda7e0652 100644 --- a/src/pages/workspace/companyCards/WorkspaceCompanyCardsSettingsPage.tsx +++ b/src/pages/workspace/companyCards/WorkspaceCompanyCardsSettingsPage.tsx @@ -180,10 +180,7 @@ function WorkspaceCompanyCardsSettingsPage({ {translate('workspace.moreFeatures.companyCards.setTransactionLiabilityDescription')} {isDirectFeedType && ( - { if (!selectedFeed) { return; @@ -194,7 +191,17 @@ function WorkspaceCompanyCardsSettingsPage({ } startCardFeedRefresh(policyID, selectedFeed, policy?.outputCurrency, currencyList, countryByIp); }} - /> + > + + + + + + {translate('workspace.companyCards.assignNewCards.title')} + {translate('workspace.companyCards.assignNewCards.description')} + + + )} {isCsvFeed && ( {translate('common.to')} - editStep(CONST.COMPANY_CARD.STEP.ASSIGNEE)} /> Date: Tue, 18 Aug 2026 12:43:08 +0200 Subject: [PATCH 04/15] refactor: extract variant styles for description --- src/CONST/index.ts | 7 + .../leaves/text/MenuItemDescription.tsx | 39 +- src/components/VacationDelegateMenuItem.tsx | 2 +- src/pages/tasks/DynamicNewTaskPage.tsx | 8 +- src/stories/MenuItemComparison.stories.tsx | 600 +++++++++++++++++- 5 files changed, 623 insertions(+), 33 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 380dde6545be..5a49bcd067cd 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -9510,6 +9510,13 @@ const CONST = { NEWDOT: 83, OAUTH: 86, }, + + MENU_ITEM: { + DESCRIPTION_VARIANT: { + SUPPORTING: 'supporting', + PROMINENT: 'prominent', + }, + }, } as const; /** Upgrade intro feature ids from UPGRADE_FEATURE_INTRO_MAPPING for Submit workspace */ diff --git a/src/components/MenuItem/leaves/text/MenuItemDescription.tsx b/src/components/MenuItem/leaves/text/MenuItemDescription.tsx index 351afeef8b19..b8d7f97685be 100644 --- a/src/components/MenuItem/leaves/text/MenuItemDescription.tsx +++ b/src/components/MenuItem/leaves/text/MenuItemDescription.tsx @@ -4,10 +4,29 @@ import Text from '@components/Text'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; +import type {ThemeStyles} from '@styles/index'; +import type {StyleUtilsType} from '@styles/utils'; import variables from '@styles/variables'; +import CONST from '@src/CONST'; + +import type {StyleProp, TextStyle} from 'react-native'; +import type {ValueOf} from 'type-fest'; + import React from 'react'; +type MenuItemDescriptionVariant = ValueOf; + +type MenuItemDescriptionVariantStyles = Record>; + +/** Typography applied on top of the shared supporting-label base, keyed by variant */ +function getDescriptionVariantStyles(styles: ThemeStyles, StyleUtils: StyleUtilsType): MenuItemDescriptionVariantStyles { + return { + [CONST.MENU_ITEM.DESCRIPTION_VARIANT.SUPPORTING]: styles.textLineHeightNormal, + [CONST.MENU_ITEM.DESCRIPTION_VARIANT.PROMINENT]: [StyleUtils.getFontSizeStyle(variables.fontSizeNormal), StyleUtils.getLineHeightStyle(variables.fontSizeNormalHeight)], + }; +} + type MenuItemDescriptionProps = { /** Text to render as the description */ children: string | number; @@ -16,32 +35,24 @@ type MenuItemDescriptionProps = { numberOfLines?: number; /** - * Which text size to render at. - * - * - `supporting` (default) — the small line that sits under a `Title`. - * - `standalone` — the larger size used when the description is the row's only text, e.g. an - * unfilled field row whose description doubles as the field's placeholder. + * Typography variant. `supporting` (default) is the small label look; `prominent` bumps the font + * to the normal size — use it for description-only rows (no title). */ - variant?: 'supporting' | 'standalone'; + variant?: MenuItemDescriptionVariant; }; /** The supporting text block of a `MenuItem.Content` */ -function MenuItemDescription({children, numberOfLines = 2, variant = 'supporting'}: MenuItemDescriptionProps) { +function MenuItemDescription({children, numberOfLines = 2, variant = CONST.MENU_ITEM.DESCRIPTION_VARIANT.SUPPORTING}: MenuItemDescriptionProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); useMenuItemAccessibilityLabel('description', String(children)); - const isStandalone = variant === 'standalone'; + const variantStyles = getDescriptionVariantStyles(styles, StyleUtils); return ( {children} diff --git a/src/components/VacationDelegateMenuItem.tsx b/src/components/VacationDelegateMenuItem.tsx index 07c102e308a0..51ae8070e23d 100644 --- a/src/components/VacationDelegateMenuItem.tsx +++ b/src/components/VacationDelegateMenuItem.tsx @@ -66,7 +66,7 @@ function VacationDelegateMenuItem({vacationDelegate, errors, pendingAction, onCl {hasVacationDelegate && {vacationDelegatePersonalDetails?.displayName ?? fallbackVacationDelegateLogin ?? ''}} {!!description && ( {description} diff --git a/src/pages/tasks/DynamicNewTaskPage.tsx b/src/pages/tasks/DynamicNewTaskPage.tsx index 2d0e76f1e08d..469b50ab0863 100644 --- a/src/pages/tasks/DynamicNewTaskPage.tsx +++ b/src/pages/tasks/DynamicNewTaskPage.tsx @@ -215,7 +215,9 @@ function DynamicNewTaskPage() { ) : ( {assignee.displayName} ))} - + {assignee?.displayName ? formatPhoneNumber(assignee?.subtitle) : translate('task.assignee')} @@ -253,7 +255,9 @@ function DynamicNewTaskPage() { ) : ( {shareDestination.displayName} ))} - + {shareDestination?.displayName ? (shareDestination.subtitle ?? '') : translate('common.share')} diff --git a/src/stories/MenuItemComparison.stories.tsx b/src/stories/MenuItemComparison.stories.tsx index 951c78b40cd7..c5ed81c5ff15 100644 --- a/src/stories/MenuItemComparison.stories.tsx +++ b/src/stories/MenuItemComparison.stories.tsx @@ -1,4 +1,9 @@ +/* eslint-disable rulesdir/prefer-actions-set-data -- stories seed Onyx directly so the ID-driven avatar cases render real data */ +import UserAvatar from '@components/Avatar/UserAvatar'; +import WorkspaceAvatar from '@components/Avatar/WorkspaceAvatar'; import CompactMenuContext from '@components/CompactMenuContext'; +import DisplayNames from '@components/DisplayNames'; +import type {DisplayNameWithTooltip} from '@components/DisplayNames/types'; import MenuItem from '@components/MenuItem'; import MenuItemAction from '@components/MenuItem/presets/MenuItemAction'; import MenuItemNavigation from '@components/MenuItem/presets/MenuItemNavigation'; @@ -7,15 +12,23 @@ import Text from '@components/Text'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useThemeStyles from '@hooks/useThemeStyles'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; + import type {Meta} from 'storybook-react-rsbuild'; import React from 'react'; import {View} from 'react-native'; +import Onyx from 'react-native-onyx'; /** * Grid comparison of the legacy `MenuItem` monolith, the new composable * `MenuItem.Root`/`Row`/... API, and the `MenuItemAction`/`MenuItemNavigation` presets. * Each card shows the same visual case built with every API that can currently express it. + * + * The "Phase 2" sections hold the avatar/leading-cell cases. The ones the compound API cannot + * express yet show the legacy render only, and their note says what is missing — they are the + * visual spec for the rest of Phase 2. */ const story: Meta = { title: 'Components/MenuItemComparison', @@ -24,6 +37,34 @@ const story: Meta = { const CARD_WIDTH = 360; +/** Account and report the ID-driven (`iconAccountID`/`iconReportID`) avatar cases resolve against */ +const STORY_ACCOUNT_ID = 90210; +const STORY_REPORT_ID = 'menuItemComparisonStoryReport'; +const STORY_POLICY_ID = 'menuItemComparisonStoryPolicy'; + +Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, { + [STORY_ACCOUNT_ID]: { + accountID: STORY_ACCOUNT_ID, + displayName: 'Alex Reed', + login: 'alex@example.com', + }, +}); + +Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${STORY_POLICY_ID}`, { + id: STORY_POLICY_ID, + name: 'Expensify Inc', +}); + +Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${STORY_REPORT_ID}`, { + reportID: STORY_REPORT_ID, + reportName: '#announce', + type: CONST.REPORT.TYPE.CHAT, + chatType: CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT, + policyID: STORY_POLICY_ID, +}); + +const STORY_TOOLTIP_DETAILS: DisplayNameWithTooltip[] = [{displayName: 'Alex Reed', accountID: STORY_ACCOUNT_ID, login: 'alex@example.com'}]; + function noop() {} function Label({children}: {children: string}) { @@ -31,20 +72,25 @@ function Label({children}: {children: string}) { return {children}; } -function Card({title, legacy, composable, preset}: {title: string; legacy: React.ReactNode; composable: React.ReactNode; preset?: React.ReactNode}) { +function Card({title, note, legacy, composable, preset}: {title: string; note?: string; legacy: React.ReactNode; composable?: React.ReactNode; preset?: React.ReactNode}) { const styles = useThemeStyles(); return ( - {title} - - {legacy} + {title} + {!!note && {note}} - - {composable} + + {legacy} + {!!composable && ( + + + {composable} + + )} {!!preset && ( @@ -55,16 +101,41 @@ function Card({title, legacy, composable, preset}: {title: string; legacy: React ); } +/** A labelled row inside a card, for cases that need several variants side by side */ +function Variant({label, children}: {label: string; children: React.ReactNode}) { + const styles = useThemeStyles(); + + return ( + + {label} + {children} + + ); +} + +function SectionHeading({title, children}: {title: string; children: string}) { + const styles = useThemeStyles(); + + return ( + + {title} + {children} + + ); +} + function Comparison() { const styles = useThemeStyles(); - const icons = useMemoizedLazyExpensifyIcons(['Gear']); + const icons = useMemoizedLazyExpensifyIcons(['Gear', 'FallbackAvatar', 'Checkmark']); - if (!icons.Gear) { + if (!icons.Gear || !icons.FallbackAvatar || !icons.Checkmark) { return null; } return ( + Cases the compound API and the Action/Navigation presets already cover. + - + + + Settings @@ -125,7 +198,9 @@ function Comparison() { composable={ - + + + Settings @@ -158,7 +233,9 @@ function Comparison() { composable={ - + + + Settings Manage your preferences @@ -204,7 +281,9 @@ function Comparison() { composable={ - + + + Settings @@ -230,7 +309,9 @@ function Comparison() { isDisabled > - + + + Settings @@ -265,7 +346,9 @@ function Comparison() { accessibilityLabel="Settings" > - + + + Settings @@ -295,7 +378,9 @@ function Comparison() { composable={ - + + + Edit columns @@ -330,7 +415,9 @@ function Comparison() { composable={ - + + + Edit columns Choose what to display @@ -343,6 +430,487 @@ function Comparison() { } /> + + + { + '`iconType={ICON_TYPE_AVATAR}` + `icon` + `avatarID`. In composition the avatar goes straight into `MenuItem.Leading` — there is no `MenuItem.Avatar`, because the avatar reads no interaction state.' + } + + + + } + composable={ + + Assignee + + + + + + + + + alex@example.com + + + + + + + } + /> + + + } + composable={ + + + + + + + Alex Reed + alex@example.com + + + + } + /> + + + } + /> + + + } + composable={ + + + + + + + Alex Reed + alex@example.com + + + + + + + } + /> + + + + + + + + + + } + composable={ + <> + + + + + Vacation delegate + + + + + + + + + + + + Alex Reed + Vacation delegate + + + + + + + + + } + /> + + + + + + + + + + + + + } + composable={ + <> + + + + + + + + Alex Reed + alex@example.com + + + + + + + + + + + + Alex Reed + alex@example.com + + + + + + + + + + + + Alex Reed + alex@example.com + + + + + + } + /> + + + } + composable={ + <> + {/* Outside Root the label loses the row's paddingHorizontal: 20, so the call site restores it */} + + Send from + + + + + + + + Expensify Inc + Workspace + + + + + + + + } + /> + + + {'`iconAccountID` / `iconReportID` render `ReportActionAvatars`, which resolves from Onyx itself and needs hover/press state for the subscript border.'} + + + + } + /> + + + } + /> + + + } + /> + + + } + /> + + + + + + + + + + } + /> + + + {'Still legacy-only. These are what `MenuItem.Leading` has to absorb as children, so that `MenuItem.Icon` never grows a prop for them.'} + + + + + + + + + + + } + /> + + + } + /> + + + } + /> ); } From 37561ceede45ef7834bbc90675cc470f3c717269 Mon Sep 17 00:00:00 2001 From: war-in Date: Tue, 18 Aug 2026 13:23:55 +0200 Subject: [PATCH 05/15] feat: add testID to root and presets --- .../MenuItem/layout/MenuItemRoot.tsx | 7 ++-- .../MenuItem/presets/MenuItemAction.tsx | 25 +++++++------- .../MenuItem/presets/MenuItemEntity.tsx | 33 ++++++++++--------- .../MenuItem/presets/MenuItemNavigation.tsx | 25 +++++++------- .../assignCard/ConfirmationStep.tsx | 9 +++-- .../members/WorkspaceMemberDetailsPage.tsx | 17 +++++----- src/types/utils/TestID.ts | 6 ++++ 7 files changed, 70 insertions(+), 52 deletions(-) create mode 100644 src/types/utils/TestID.ts diff --git a/src/components/MenuItem/layout/MenuItemRoot.tsx b/src/components/MenuItem/layout/MenuItemRoot.tsx index 085809d9f233..153fa09f9f03 100644 --- a/src/components/MenuItem/layout/MenuItemRoot.tsx +++ b/src/components/MenuItem/layout/MenuItemRoot.tsx @@ -14,6 +14,7 @@ import variables from '@styles/variables'; import CONST from '@src/CONST'; import type WithSentryLabel from '@src/types/utils/SentryLabel'; +import type WithTestID from '@src/types/utils/TestID'; import type {PropsWithChildren} from 'react'; import type {GestureResponderEvent, StyleProp, ViewStyle} from 'react-native'; @@ -22,7 +23,8 @@ import React, {useRef} from 'react'; import {View} from 'react-native'; type MenuItemRootProps = PropsWithChildren & - WithSentryLabel & { + WithSentryLabel & + WithTestID & { /** Function to fire when the row is pressed */ onPress?: (event: GestureResponderEvent | KeyboardEvent) => void | Promise; @@ -37,7 +39,7 @@ type MenuItemRootProps = PropsWithChildren & accessibilityLabel?: string; }; -function MenuItemRoot({children, onPress, isDisabled = false, sentryLabel, accessibilityLabel}: MenuItemRootProps) { +function MenuItemRoot({children, onPress, isDisabled = false, sentryLabel, testID, accessibilityLabel}: MenuItemRootProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const pressableRef = useRef(null); @@ -89,6 +91,7 @@ function MenuItemRoot({children, onPress, isDisabled = false, sentryLabel, acces accessible tabIndex={isInteractive ? 0 : -1} sentryLabel={sentryLabel} + testID={testID} > {({pressed}) => ( diff --git a/src/components/MenuItem/presets/MenuItemAction.tsx b/src/components/MenuItem/presets/MenuItemAction.tsx index 6dd7f1b18db3..31007833738f 100644 --- a/src/components/MenuItem/presets/MenuItemAction.tsx +++ b/src/components/MenuItem/presets/MenuItemAction.tsx @@ -9,35 +9,38 @@ import {callFunctionIfActionIsAllowed} from '@userActions/Session'; import type IconAsset from '@src/types/utils/IconAsset'; import type WithSentryLabel from '@src/types/utils/SentryLabel'; +import type WithTestID from '@src/types/utils/TestID'; import type {GestureResponderEvent} from 'react-native'; import React from 'react'; -type MenuItemActionProps = WithSentryLabel & { - /** The title text of the row */ - title: string; +type MenuItemActionProps = WithSentryLabel & + WithTestID & { + /** The title text of the row */ + title: string; - /** Leading icon to display */ - icon: IconAsset; + /** Leading icon to display */ + icon: IconAsset; - /** Function to fire when the row is pressed */ - onPress: (event: GestureResponderEvent | KeyboardEvent) => void | Promise; + /** Function to fire when the row is pressed */ + onPress: (event: GestureResponderEvent | KeyboardEvent) => void | Promise; - /** Whether the menu item is disabled */ - isDisabled?: boolean; -}; + /** Whether the menu item is disabled */ + isDisabled?: boolean; + }; /** * The action MenuItem preset — a tappable row with a leading icon and a title that performs an * action in place (delete, select, add, etc.) */ -function MenuItemAction({title, icon, onPress, isDisabled = false, sentryLabel}: MenuItemActionProps) { +function MenuItemAction({title, icon, onPress, isDisabled = false, sentryLabel, testID}: MenuItemActionProps) { return ( diff --git a/src/components/MenuItem/presets/MenuItemEntity.tsx b/src/components/MenuItem/presets/MenuItemEntity.tsx index bcc9acc2f421..fe4ba020bbc6 100644 --- a/src/components/MenuItem/presets/MenuItemEntity.tsx +++ b/src/components/MenuItem/presets/MenuItemEntity.tsx @@ -13,41 +13,44 @@ import type {AvatarSource} from '@libs/UserAvatarUtils'; import {callFunctionIfActionIsAllowed} from '@userActions/Session'; import type WithSentryLabel from '@src/types/utils/SentryLabel'; +import type WithTestID from '@src/types/utils/TestID'; import type {GestureResponderEvent} from 'react-native'; import React from 'react'; -type MenuItemEntityProps = WithSentryLabel & { - /** The entity's name, rendered as the row's title */ - title: string; +type MenuItemEntityProps = WithSentryLabel & + WithTestID & { + /** The entity's name, rendered as the row's title */ + title: string; - /** Supporting line under the title — an email, address, or other secondary identifier */ - description: string; + /** Supporting line under the title — an email, address, or other secondary identifier */ + description: string; - /** Account ID the avatar belongs to. Picks the default avatar when `avatarSource` is absent */ - accountID: number; + /** Account ID the avatar belongs to. Picks the default avatar when `avatarSource` is absent */ + accountID: number; - /** The entity's avatar. Falls back to the default avatar for `accountID` when omitted */ - avatarSource?: AvatarSource; + /** The entity's avatar. Falls back to the default avatar for `accountID` when omitted */ + avatarSource?: AvatarSource; - /** Function to fire when the row is pressed */ - onPress: (event: GestureResponderEvent | KeyboardEvent) => void | Promise; + /** Function to fire when the row is pressed */ + onPress: (event: GestureResponderEvent | KeyboardEvent) => void | Promise; - /** Whether the menu item is disabled */ - isDisabled?: boolean; -}; + /** Whether the menu item is disabled */ + isDisabled?: boolean; + }; /** * The entity MenuItem preset — a tappable row led by a person's avatar, with their name as the title * and a secondary identifier below, that navigates. */ -function MenuItemEntity({title, description, accountID, avatarSource, onPress, isDisabled = false, sentryLabel}: MenuItemEntityProps) { +function MenuItemEntity({title, description, accountID, avatarSource, onPress, isDisabled = false, sentryLabel, testID}: MenuItemEntityProps) { return ( diff --git a/src/components/MenuItem/presets/MenuItemNavigation.tsx b/src/components/MenuItem/presets/MenuItemNavigation.tsx index 740ddcc6567b..7dfb2fd48110 100644 --- a/src/components/MenuItem/presets/MenuItemNavigation.tsx +++ b/src/components/MenuItem/presets/MenuItemNavigation.tsx @@ -11,35 +11,38 @@ import {callFunctionIfActionIsAllowed} from '@userActions/Session'; import type IconAsset from '@src/types/utils/IconAsset'; import type WithSentryLabel from '@src/types/utils/SentryLabel'; +import type WithTestID from '@src/types/utils/TestID'; import type {GestureResponderEvent} from 'react-native'; import React from 'react'; -type MenuItemNavigationProps = WithSentryLabel & { - /** The title text of the row */ - title: string; +type MenuItemNavigationProps = WithSentryLabel & + WithTestID & { + /** The title text of the row */ + title: string; - /** Leading icon to display */ - icon: IconAsset; + /** Leading icon to display */ + icon: IconAsset; - /** Function to fire when the row is pressed */ - onPress: (event: GestureResponderEvent | KeyboardEvent) => void | Promise; + /** Function to fire when the row is pressed */ + onPress: (event: GestureResponderEvent | KeyboardEvent) => void | Promise; - /** Whether the menu item is disabled */ - isDisabled?: boolean; -}; + /** Whether the menu item is disabled */ + isDisabled?: boolean; + }; /** * The navigation MenuItem preset — a tappable row with a leading icon, a title, and a trailing * chevron signaling that pressing it takes the user somewhere else */ -function MenuItemNavigation({title, icon, onPress, isDisabled = false, sentryLabel}: MenuItemNavigationProps) { +function MenuItemNavigation({title, icon, onPress, isDisabled = false, sentryLabel, testID}: MenuItemNavigationProps) { return ( diff --git a/src/pages/workspace/companyCards/assignCard/ConfirmationStep.tsx b/src/pages/workspace/companyCards/assignCard/ConfirmationStep.tsx index 832c73819e82..6d706fa0af40 100644 --- a/src/pages/workspace/companyCards/assignCard/ConfirmationStep.tsx +++ b/src/pages/workspace/companyCards/assignCard/ConfirmationStep.tsx @@ -190,13 +190,12 @@ function ConfirmationStep({route}: ConfirmationStepProps) { {translate('common.to')} - editStep(CONST.COMPANY_CARD.STEP.ASSIGNEE)} /> )} - + + + {memberCards.length > 0 && ( <> diff --git a/src/types/utils/TestID.ts b/src/types/utils/TestID.ts new file mode 100644 index 000000000000..b99722ab2b34 --- /dev/null +++ b/src/types/utils/TestID.ts @@ -0,0 +1,6 @@ +/** Test ID used to locate a component in tests */ +type WithTestID = { + testID?: string; +}; + +export default WithTestID; From 61809ab51f2c807872643746d969f79af7c8023a Mon Sep 17 00:00:00 2001 From: war-in Date: Tue, 18 Aug 2026 14:27:50 +0200 Subject: [PATCH 06/15] chore: MenuItemComparison.stories.tsx update --- src/stories/MenuItemComparison.stories.tsx | 1146 +++++++++++--------- 1 file changed, 606 insertions(+), 540 deletions(-) diff --git a/src/stories/MenuItemComparison.stories.tsx b/src/stories/MenuItemComparison.stories.tsx index c5ed81c5ff15..57fe83f80d14 100644 --- a/src/stories/MenuItemComparison.stories.tsx +++ b/src/stories/MenuItemComparison.stories.tsx @@ -1,12 +1,15 @@ /* eslint-disable rulesdir/prefer-actions-set-data -- stories seed Onyx directly so the ID-driven avatar cases render real data */ import UserAvatar from '@components/Avatar/UserAvatar'; import WorkspaceAvatar from '@components/Avatar/WorkspaceAvatar'; +import Button from '@components/ButtonComposed'; import CompactMenuContext from '@components/CompactMenuContext'; import DisplayNames from '@components/DisplayNames'; import type {DisplayNameWithTooltip} from '@components/DisplayNames/types'; import MenuItem from '@components/MenuItem'; import MenuItemAction from '@components/MenuItem/presets/MenuItemAction'; +import MenuItemEntity from '@components/MenuItem/presets/MenuItemEntity'; import MenuItemNavigation from '@components/MenuItem/presets/MenuItemNavigation'; +import ReportActionAvatars from '@components/ReportActionAvatars'; import Text from '@components/Text'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; @@ -26,9 +29,9 @@ import Onyx from 'react-native-onyx'; * `MenuItem.Root`/`Row`/... API, and the `MenuItemAction`/`MenuItemNavigation` presets. * Each card shows the same visual case built with every API that can currently express it. * - * The "Phase 2" sections hold the avatar/leading-cell cases. The ones the compound API cannot - * express yet show the legacy render only, and their note says what is missing — they are the - * visual spec for the rest of Phase 2. + * The "Phase 2" avatar sections lead, because those rows are what is under review, and they carry no + * prose — they are there purely to compare renders. Cards the compound API cannot express yet show + * the legacy render only. The Phase 1 icon rows come last as the already-settled baseline. */ const story: Meta = { title: 'Components/MenuItemComparison', @@ -113,84 +116,117 @@ function Variant({label, children}: {label: string; children: React.ReactNode}) ); } -function SectionHeading({title, children}: {title: string; children: string}) { +function SectionHeading({title, children}: {title: string; children?: string}) { const styles = useThemeStyles(); return ( {title} - {children} + {!!children && {children}} ); } function Comparison() { const styles = useThemeStyles(); - const icons = useMemoizedLazyExpensifyIcons(['Gear', 'FallbackAvatar', 'Checkmark']); + const icons = useMemoizedLazyExpensifyIcons(['Gear', 'FallbackAvatar']); - if (!icons.Gear || !icons.FallbackAvatar || !icons.Checkmark) { + if (!icons.Gear || !icons.FallbackAvatar) { return null; } return ( - Cases the compound API and the Action/Navigation presets already cover. + } composable={ + Assignee + + + - Settings + + + + alex@example.com + + + } /> } composable={ - - - - - - - Settings - - - - } - preset={ - + + + + + + + + Alexandra Reed-Fitzgerald + alexandra.reed.fitzgerald.with.a.very.long.address@example.com + + + + } /> @@ -199,10 +235,14 @@ function Comparison() { - + - Settings + Alex Reed + alex@example.com @@ -211,243 +251,264 @@ function Comparison() { } preset={ - } /> + <> + + + + + + + } - composable={ - - - - - - - Settings - Manage your preferences - - - - - - + preset={ + <> + + + + + + + } /> + <> + + + + + + + } composable={ - - - - Settings - Manage your preferences - - - + <> + + + + + Vacation delegate + + + + + + + + + + + + Alex Reed + Vacation delegate + + + + + + + + } /> + <> + + + + + + + + + + } composable={ - - - - - - - Settings - - - - } - /> - - - } - composable={ - - - - - - - Settings - - - - - - - } - preset={ - + <> + + + + + + + + Alex Reed + alex@example.com + + + + + + + + + + + + Alex Reed + alex@example.com + + + + + + + + + + + + Alex Reed + alex@example.com + + + + + } /> } composable={ - - - - - - - Settings - - - - } - preset={ - - } - /> - - - - } - composable={ - - - - - - - Edit columns - - - - - - - } - preset={ - - } - /> - - - - - } - composable={ + <> + {/* Outside Root the label loses the row's paddingHorizontal: 20, so the call site restores it */} + + Send from + - + - Edit columns - Choose what to display + Expensify Inc + Workspace - } - /> - - - - { - '`iconType={ICON_TYPE_AVATAR}` + `icon` + `avatarID`. In composition the avatar goes straight into `MenuItem.Leading` — there is no `MenuItem.Avatar`, because the avatar reads no interaction state.' + } - + /> - Assignee + + Assignee + - @@ -483,434 +546,437 @@ function Comparison() { /> } composable={ - + + + Share + - - Alex Reed - alex@example.com + Expensify Inc + + Required + + } /> - } - /> - - } composable={ - - - - + Assignee + + + Alex Reed - alex@example.com - - - } /> + Cases the compound API and the Action/Navigation presets already cover. + - - - - - - - + } composable={ - <> - - - - - Vacation delegate - - - - - - - - - - - - Alex Reed - Vacation delegate - - - - - - - - + + + + Settings + + + } /> - - - - - - - - - - + } composable={ - <> - - - - - - - - Alex Reed - alex@example.com - - - - - - - - - - - - Alex Reed - alex@example.com - - - - - - - - - - - - Alex Reed - alex@example.com - - - - - + + + + + + + Settings + + + + } + preset={ + } /> } composable={ - <> - {/* Outside Root the label loses the row's paddingHorizontal: 20, so the call site restores it */} - - Send from - - - - - - - - Expensify Inc - Workspace - - - - - - - + + + + + + + Settings + + + + + + + } + preset={ + } /> - - {'`iconAccountID` / `iconReportID` render `ReportActionAvatars`, which resolves from Onyx itself and needs hover/press state for the subscript border.'} - - } + composable={ + + + + + + + Settings + Manage your preferences + + + + + + + } /> } + composable={ + + + + + + + Create report + Expensify Inc + + + + } /> + + + } /> } + composable={ + + + + This device + + + + + + + } /> } - /> - - - - - - - - - + composable={ + + + + Settings + Manage your preferences + + + } /> - - {'Still legacy-only. These are what `MenuItem.Leading` has to absorb as children, so that `MenuItem.Icon` never grows a prop for them.'} - - - - - - - - - + + } + composable={ + + + + + + + Settings + + + } /> + } + composable={ + + + + + + + Settings + + + + + + + } + preset={ + } /> + } + composable={ + + + + + + + Settings + + + + } + preset={ + } /> + + + + } + composable={ + + + + + + + Edit columns + + + + + + + } + preset={ + + } + /> + + + + + } + composable={ + + + + + + + Edit columns + Choose what to display + + + + + + + } + /> + ); } From f40483160fb4f21c53d051fe18e8789ff78e2fb7 Mon Sep 17 00:00:00 2001 From: war-in Date: Tue, 18 Aug 2026 16:35:43 +0200 Subject: [PATCH 07/15] fix: move description variants to MenuItemDescription --- src/CONST/index.ts | 7 ---- .../leaves/text/MenuItemDescription.tsx | 35 ++++++++++--------- src/components/VacationDelegateMenuItem.tsx | 6 ++-- src/stories/MenuItemComparison.stories.tsx | 5 +-- 4 files changed, 25 insertions(+), 28 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 9088217e3cd2..5e31979330bd 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -9519,13 +9519,6 @@ const CONST = { NEWDOT: 83, OAUTH: 86, }, - - MENU_ITEM: { - DESCRIPTION_VARIANT: { - SUPPORTING: 'supporting', - PROMINENT: 'prominent', - }, - }, } as const; /** Upgrade intro feature ids from UPGRADE_FEATURE_INTRO_MAPPING for Submit workspace */ diff --git a/src/components/MenuItem/leaves/text/MenuItemDescription.tsx b/src/components/MenuItem/leaves/text/MenuItemDescription.tsx index b8d7f97685be..1fce3ca2e0a3 100644 --- a/src/components/MenuItem/leaves/text/MenuItemDescription.tsx +++ b/src/components/MenuItem/leaves/text/MenuItemDescription.tsx @@ -4,28 +4,24 @@ import Text from '@components/Text'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; -import type {ThemeStyles} from '@styles/index'; -import type {StyleUtilsType} from '@styles/utils'; import variables from '@styles/variables'; -import CONST from '@src/CONST'; - import type {StyleProp, TextStyle} from 'react-native'; import type {ValueOf} from 'type-fest'; import React from 'react'; -type MenuItemDescriptionVariant = ValueOf; +const MENU_ITEM_DESCRIPTION_VARIANT = { + /** The small supporting-label look, for a description that sits under a title */ + SUPPORTING: 'supporting', -type MenuItemDescriptionVariantStyles = Record>; + /** Normal-size text, for a description standing in for a value the row does not have yet */ + PLACEHOLDER: 'placeholder', +} as const; -/** Typography applied on top of the shared supporting-label base, keyed by variant */ -function getDescriptionVariantStyles(styles: ThemeStyles, StyleUtils: StyleUtilsType): MenuItemDescriptionVariantStyles { - return { - [CONST.MENU_ITEM.DESCRIPTION_VARIANT.SUPPORTING]: styles.textLineHeightNormal, - [CONST.MENU_ITEM.DESCRIPTION_VARIANT.PROMINENT]: [StyleUtils.getFontSizeStyle(variables.fontSizeNormal), StyleUtils.getLineHeightStyle(variables.fontSizeNormalHeight)], - }; -} +type MenuItemDescriptionVariant = ValueOf; + +type MenuItemDescriptionVariantStyles = Record>; type MenuItemDescriptionProps = { /** Text to render as the description */ @@ -35,20 +31,24 @@ type MenuItemDescriptionProps = { numberOfLines?: number; /** - * Typography variant. `supporting` (default) is the small label look; `prominent` bumps the font - * to the normal size — use it for description-only rows (no title). + * Typography variant. `supporting` (default) is the small label look; `placeholder` bumps the font to + * the normal size — use it on rows with no title, where the description carries the row on its own. */ variant?: MenuItemDescriptionVariant; }; /** The supporting text block of a `MenuItem.Content` */ -function MenuItemDescription({children, numberOfLines = 2, variant = CONST.MENU_ITEM.DESCRIPTION_VARIANT.SUPPORTING}: MenuItemDescriptionProps) { +function MenuItemDescription({children, numberOfLines = 2, variant = MENU_ITEM_DESCRIPTION_VARIANT.SUPPORTING}: MenuItemDescriptionProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); useMenuItemAccessibilityLabel('description', String(children)); - const variantStyles = getDescriptionVariantStyles(styles, StyleUtils); + /** Typography applied on top of the shared supporting-label base, keyed by variant */ + const variantStyles: MenuItemDescriptionVariantStyles = { + [MENU_ITEM_DESCRIPTION_VARIANT.SUPPORTING]: styles.textLineHeightNormal, + [MENU_ITEM_DESCRIPTION_VARIANT.PLACEHOLDER]: [StyleUtils.getFontSizeStyle(variables.fontSizeNormal), StyleUtils.getLineHeightStyle(variables.fontSizeNormalHeight)], + }; return ( @@ -63,10 +65,10 @@ function VacationDelegateMenuItem({vacationDelegate, errors, pendingAction, onCl )} - {hasVacationDelegate && {vacationDelegatePersonalDetails?.displayName ?? fallbackVacationDelegateLogin ?? ''}} + {!!title && {title}} {!!description && ( {description} diff --git a/src/stories/MenuItemComparison.stories.tsx b/src/stories/MenuItemComparison.stories.tsx index 57fe83f80d14..7c3bfbb439e9 100644 --- a/src/stories/MenuItemComparison.stories.tsx +++ b/src/stories/MenuItemComparison.stories.tsx @@ -6,6 +6,7 @@ import CompactMenuContext from '@components/CompactMenuContext'; import DisplayNames from '@components/DisplayNames'; import type {DisplayNameWithTooltip} from '@components/DisplayNames/types'; import MenuItem from '@components/MenuItem'; +import {MENU_ITEM_DESCRIPTION_VARIANT} from '@components/MenuItem/leaves/text/MenuItemDescription'; import MenuItemAction from '@components/MenuItem/presets/MenuItemAction'; import MenuItemEntity from '@components/MenuItem/presets/MenuItemEntity'; import MenuItemNavigation from '@components/MenuItem/presets/MenuItemNavigation'; @@ -337,11 +338,11 @@ function Comparison() { } composable={ <> - + - Vacation delegate + Vacation delegate From d2df09616eb6b9112aedd2abd8ef1087deee2b47 Mon Sep 17 00:00:00 2001 From: war-in Date: Tue, 18 Aug 2026 16:36:14 +0200 Subject: [PATCH 08/15] fix: improve spacing and correctness --- src/components/DelegatorList.tsx | 25 +-- .../sections/InvoiceSenderField.tsx | 7 +- .../MultifactorAuthentication/RevokePage.tsx | 94 +++++---- .../ScheduleCallConfirmationPage.tsx | 42 ++-- src/pages/tasks/DynamicNewTaskPage.tsx | 189 ++++++++++-------- 5 files changed, 196 insertions(+), 161 deletions(-) diff --git a/src/components/DelegatorList.tsx b/src/components/DelegatorList.tsx index db6d83211a4e..06fce77dc3cb 100644 --- a/src/components/DelegatorList.tsx +++ b/src/components/DelegatorList.tsx @@ -29,17 +29,14 @@ function DelegatorList({delegators, message}: DelegatorListProps) { return ( <> {message} - {delegators?.map((delegatorEmail) => { - const delegatorDetails = personalDetailsByLogin[delegatorEmail.toLowerCase()]; - const formattedLogin = formatPhoneNumber(delegatorDetails?.login ?? ''); - const displayLogin = formattedLogin || delegatorEmail; - - return ( - - + + {delegators?.map((delegatorEmail) => { + const delegatorDetails = personalDetailsByLogin[delegatorEmail.toLowerCase()]; + const formattedLogin = formatPhoneNumber(delegatorDetails?.login ?? ''); + const displayLogin = formattedLogin || delegatorEmail; + + return ( + - - ); - })} + ); + })} + ); } diff --git a/src/components/MoneyRequestConfirmationList/sections/InvoiceSenderField.tsx b/src/components/MoneyRequestConfirmationList/sections/InvoiceSenderField.tsx index 6a679d62608c..cb6b9aa9e2b8 100644 --- a/src/components/MoneyRequestConfirmationList/sections/InvoiceSenderField.tsx +++ b/src/components/MoneyRequestConfirmationList/sections/InvoiceSenderField.tsx @@ -1,5 +1,6 @@ import WorkspaceAvatar from '@components/Avatar/WorkspaceAvatar'; import MenuItem from '@components/MenuItem'; +import {MENU_ITEM_DESCRIPTION_VARIANT} from '@components/MenuItem/leaves/text/MenuItemDescription'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; @@ -90,8 +91,10 @@ function InvoiceSenderField({selectedParticipants, isReadOnly, didConfirm, trans /> - {senderWorkspace?.name ?? ''} - {translate('workspace.common.workspace')} + {!!senderWorkspace?.name && {senderWorkspace.name}} + + {translate('workspace.common.workspace')} + {isInteractive && ( diff --git a/src/pages/MultifactorAuthentication/RevokePage.tsx b/src/pages/MultifactorAuthentication/RevokePage.tsx index fdbb2af0e7a6..f213a042e0a3 100644 --- a/src/pages/MultifactorAuthentication/RevokePage.tsx +++ b/src/pages/MultifactorAuthentication/RevokePage.tsx @@ -31,6 +31,42 @@ const confirmPromptKeys = { all: 'multifactorAuthentication.revoke.confirmationPromptAll', } as const; +type RevokeRowProps = { + /** Which set of devices this row revokes */ + title: string; + + /** Whether this row's revoke request is in flight */ + isLoading: boolean; + + /** Opens the confirmation modal for this row's set of devices */ + onPress: () => void; +}; + +/** A non-interactive row naming a set of registered devices, with a `Revoke` button in the trailing cell */ +function RevokeRow({title, isLoading, onPress}: RevokeRowProps) { + const {translate} = useLocalize(); + + return ( + + + + {title} + + + + + + + ); +} + /** * Revoke page for multifactor authentication (biometric/passkey) credentials. * @@ -188,49 +224,25 @@ function MultifactorAuthenticationRevokePage() { {/* The isCurrentDeviceRegistered guard guarantees localCredentialID is truthy here. Do not remove this guard without updating the non-null assertion on localCredentialID below. */} {isCurrentDeviceRegistered && ( - - - - {translate('multifactorAuthentication.revoke.thisDevice')} - - - - - - + { + if (!localCredentialID) { + return; + } + showConfirmModal('thisDevice'); + }} + /> )} {otherDeviceCount > 0 && ( - - - - {translate('multifactorAuthentication.revoke.otherDevices', otherDeviceCount)} - - - - - - + { + showConfirmModal(otherDevicesConfirmMode()); + }} + /> )} )} diff --git a/src/pages/ScheduleCall/ScheduleCallConfirmationPage.tsx b/src/pages/ScheduleCall/ScheduleCallConfirmationPage.tsx index 205819e75260..fba8b7c90cdf 100644 --- a/src/pages/ScheduleCall/ScheduleCallConfirmationPage.tsx +++ b/src/pages/ScheduleCall/ScheduleCallConfirmationPage.tsx @@ -109,34 +109,31 @@ function ScheduleCallConfirmationPage() { }} /> - - {translate('scheduledCall.confirmation.description')} - - - - {translate('scheduledCall.confirmation.setupSpecialist')} - - - - - - - {guideDetails?.displayName ?? ''} - {!!guideDetails?.login && {guideDetails.login}} - - - - + + {translate('scheduledCall.confirmation.description')} + + + {translate('scheduledCall.confirmation.setupSpecialist')} + + + + + + + {!!guideDetails?.displayName && {guideDetails.displayName}} + {!!guideDetails?.login && {guideDetails.login}} + + + { if (!route?.params?.reportID) { return; @@ -148,7 +145,6 @@ function ScheduleCallConfirmationPage() { title={translate('scheduledCall.confirmation.minutes')} description={translate('scheduledCall.confirmation.meetingLength')} interactive={false} - style={styles.mb3} /> diff --git a/src/pages/tasks/DynamicNewTaskPage.tsx b/src/pages/tasks/DynamicNewTaskPage.tsx index 469b50ab0863..7e197dd54aa2 100644 --- a/src/pages/tasks/DynamicNewTaskPage.tsx +++ b/src/pages/tasks/DynamicNewTaskPage.tsx @@ -1,9 +1,11 @@ import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; import DisplayNames from '@components/DisplayNames'; +import type {DisplayNameWithTooltip} from '@components/DisplayNames/types'; import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton'; import FormHelpMessage from '@components/FormHelpMessage'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import MenuItem from '@components/MenuItem'; +import {MENU_ITEM_DESCRIPTION_VARIANT} from '@components/MenuItem/leaves/text/MenuItemDescription'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import ReportActionAvatars from '@components/ReportActionAvatars'; import ScreenWrapper from '@components/ScreenWrapper'; @@ -36,6 +38,80 @@ import {isEmptyObject} from '@src/types/utils/EmptyObject'; import React, {useEffect, useRef, useState} from 'react'; import {View} from 'react-native'; +type TaskFieldRowProps = { + /** Name of the field, shown above the row once the field has a value */ + label: string; + + /** Avatars for the selected value. Falsy while the field is empty, which drops the leading cell */ + avatars?: React.ReactNode; + + /** Display name of the selected value. Absent while the field is empty */ + displayName?: string; + + /** Per-name tooltips for `displayName`. Falls back to plain text when empty */ + displayNamesWithTooltips?: DisplayNameWithTooltip[]; + + /** Supporting line under the title. With no `displayName` it carries the row on its own */ + description: string; + + /** Whether to show the `Required` hint in the trailing cell */ + shouldShowRequiredLabel?: boolean; + + /** Whether to show the trailing chevron */ + shouldShowChevron?: boolean; + + /** Function to fire when the row is pressed. Omit to make the row non-interactive */ + onPress?: () => void; +}; + +/** + * One of the task's participant fields (assignee, share destination). Both render the same shape: the + * field name on top once a value is picked, then that value's avatar, name and secondary line. + */ +function TaskFieldRow({label, avatars, displayName, displayNamesWithTooltips, description, shouldShowRequiredLabel = false, shouldShowChevron = true, onPress}: TaskFieldRowProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + + const hasValue = !!displayName; + + return ( + + {hasValue && ( + + {label} + + )} + + {!!avatars && {avatars}} + + {hasValue && + (displayNamesWithTooltips?.length ? ( + + + + ) : ( + {displayName} + ))} + {!!description && ( + {description} + )} + + {(shouldShowRequiredLabel || shouldShowChevron) && ( + + {shouldShowRequiredLabel && {translate('common.required')}} + {shouldShowChevron && } + + )} + + + ); +} + function DynamicNewTaskPage() { const [task] = useOnyx(ONYXKEYS.TASK); const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${task?.shareDestination}`); @@ -186,87 +262,38 @@ function DynamicNewTaskPage() { numberOfLinesTitle={2} titleStyle={styles.flex1} /> - Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.NEW_TASK_ASSIGNEE.path))}> - {!!assignee?.displayName && ( - - {translate('task.assignee')} - - )} - - {!!task?.assigneeAccountID && ( - - - - )} - - {!!assignee?.displayName && - (assigneeTooltipDetails.length > 0 ? ( - - - - ) : ( - {assignee.displayName} - ))} - - {assignee?.displayName ? formatPhoneNumber(assignee?.subtitle) : translate('task.assignee')} - - - - - - - - Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.NEW_TASK_SHARE_DESTINATION.path))}> - {!!shareDestination?.displayName && ( - - {translate('common.share')} - - )} - - {!!task?.shareDestination && ( - - - - )} - - {!!shareDestination?.displayName && - (!shareDestination.shouldUseFullTitleToDisplay && shareDestination.displayNamesWithTooltips.length > 0 ? ( - - - - ) : ( - {shareDestination.displayName} - ))} - - {shareDestination?.displayName ? (shareDestination.subtitle ?? '') : translate('common.share')} - - - - {!task?.shareDestination && {translate('common.required')}} - {!task?.parentReportID && } - - - + + ) + } + displayName={assignee?.displayName} + displayNamesWithTooltips={assigneeTooltipDetails} + description={assignee?.displayName ? formatPhoneNumber(assignee?.subtitle) : translate('task.assignee')} + onPress={() => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.NEW_TASK_ASSIGNEE.path))} + /> + + ) + } + displayName={shareDestination?.displayName} + displayNamesWithTooltips={shareDestination?.shouldUseFullTitleToDisplay ? undefined : shareDestination?.displayNamesWithTooltips} + description={shareDestination?.displayName ? (shareDestination.subtitle ?? '') : translate('common.share')} + shouldShowRequiredLabel={!shareDestination?.displayName} + shouldShowChevron={!task?.parentReportID} + onPress={task?.parentReportID ? undefined : () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.NEW_TASK_SHARE_DESTINATION.path))} + /> From e1333cc5d2784f2b696c5391e730b5dfe7550a91 Mon Sep 17 00:00:00 2001 From: war-in Date: Tue, 18 Aug 2026 16:36:44 +0200 Subject: [PATCH 09/15] fix: pass `accessibilityLabel` to skip rerenders --- src/components/MenuItem/presets/MenuItemEntity.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/MenuItem/presets/MenuItemEntity.tsx b/src/components/MenuItem/presets/MenuItemEntity.tsx index fe4ba020bbc6..7d8a08351dd1 100644 --- a/src/components/MenuItem/presets/MenuItemEntity.tsx +++ b/src/components/MenuItem/presets/MenuItemEntity.tsx @@ -51,6 +51,7 @@ function MenuItemEntity({title, description, accountID, avatarSource, onPress, i isDisabled={isDisabled} sentryLabel={sentryLabel} testID={testID} + accessibilityLabel={[title, description].filter(Boolean).join(', ')} > From 0dd9b7c92ad10327f737815168d46e1a74465943 Mon Sep 17 00:00:00 2001 From: war-in Date: Fri, 21 Aug 2026 08:37:59 +0200 Subject: [PATCH 10/15] feat: add MenuItemField --- src/components/MenuItem/index.ts | 2 + .../leaves/trailing/MenuItemRightLabel.tsx | 19 +++ .../MenuItem/presets/MenuItemEmptyField.tsx | 54 ++++++ .../MenuItem/presets/MenuItemField.tsx | 86 ++++++++++ src/components/VacationDelegateMenuItem.tsx | 75 +++------ src/pages/tasks/DynamicNewTaskPage.tsx | 159 +++++++----------- 6 files changed, 247 insertions(+), 148 deletions(-) create mode 100644 src/components/MenuItem/leaves/trailing/MenuItemRightLabel.tsx create mode 100644 src/components/MenuItem/presets/MenuItemEmptyField.tsx create mode 100644 src/components/MenuItem/presets/MenuItemField.tsx diff --git a/src/components/MenuItem/index.ts b/src/components/MenuItem/index.ts index 76f9d7639302..70ff64905776 100644 --- a/src/components/MenuItem/index.ts +++ b/src/components/MenuItem/index.ts @@ -36,6 +36,7 @@ import MenuItemDescription from './leaves/text/MenuItemDescription'; import MenuItemLabel from './leaves/text/MenuItemLabel'; import MenuItemTitle from './leaves/text/MenuItemTitle'; import MenuItemChevron from './leaves/trailing/MenuItemChevron'; +import MenuItemRightLabel from './leaves/trailing/MenuItemRightLabel'; import LegacyMenuItem from './MenuItem'; const MenuItem = Object.assign(LegacyMenuItem, { @@ -49,6 +50,7 @@ const MenuItem = Object.assign(LegacyMenuItem, { Title: MenuItemTitle, Description: MenuItemDescription, Chevron: MenuItemChevron, + RightLabel: MenuItemRightLabel, }); export default MenuItem; diff --git a/src/components/MenuItem/leaves/trailing/MenuItemRightLabel.tsx b/src/components/MenuItem/leaves/trailing/MenuItemRightLabel.tsx new file mode 100644 index 000000000000..a3e40f22926a --- /dev/null +++ b/src/components/MenuItem/leaves/trailing/MenuItemRightLabel.tsx @@ -0,0 +1,19 @@ +import Text from '@components/Text'; + +import useThemeStyles from '@hooks/useThemeStyles'; + +import React from 'react'; + +type MenuItemRightLabelProps = { + /** Text to render as the label */ + children: string; +}; + +/** A short trailing hint of a `MenuItem.Row`, such as a `Required` marker */ +function MenuItemRightLabel({children}: MenuItemRightLabelProps) { + const styles = useThemeStyles(); + + return {children}; +} + +export default MenuItemRightLabel; diff --git a/src/components/MenuItem/presets/MenuItemEmptyField.tsx b/src/components/MenuItem/presets/MenuItemEmptyField.tsx new file mode 100644 index 000000000000..6b80d62ed2fb --- /dev/null +++ b/src/components/MenuItem/presets/MenuItemEmptyField.tsx @@ -0,0 +1,54 @@ +import MenuItemContent from '@components/MenuItem/layout/MenuItemContent'; +import MenuItemRoot from '@components/MenuItem/layout/MenuItemRoot'; +import MenuItemRow from '@components/MenuItem/layout/MenuItemRow'; +import MenuItemTrailing from '@components/MenuItem/layout/MenuItemTrailing'; +import MenuItemDescription, {MENU_ITEM_DESCRIPTION_VARIANT} from '@components/MenuItem/leaves/text/MenuItemDescription'; +import MenuItemChevron from '@components/MenuItem/leaves/trailing/MenuItemChevron'; + +import {callFunctionIfActionIsAllowed} from '@userActions/Session'; + +import type WithSentryLabel from '@src/types/utils/SentryLabel'; +import type WithTestID from '@src/types/utils/TestID'; + +import type {PropsWithChildren} from 'react'; +import type {GestureResponderEvent} from 'react-native'; + +import React from 'react'; + +type MenuItemEmptyFieldProps = PropsWithChildren & + WithSentryLabel & + WithTestID & { + /** Name of the field, standing in for the value the field does not have yet */ + description: string; + + /** Function to fire when the row is pressed */ + onPress: (event: GestureResponderEvent | KeyboardEvent) => void | Promise; + + /** Whether the menu item is disabled */ + isDisabled?: boolean; + }; + +/** The empty-field MenuItem preset — a form field the user has not filled in yet */ +function MenuItemEmptyField({description, onPress, children, isDisabled = false, sentryLabel, testID}: MenuItemEmptyFieldProps) { + return ( + + + + {description} + + + {children} + + + + + ); +} + +export default MenuItemEmptyField; diff --git a/src/components/MenuItem/presets/MenuItemField.tsx b/src/components/MenuItem/presets/MenuItemField.tsx new file mode 100644 index 000000000000..a6eb60955f85 --- /dev/null +++ b/src/components/MenuItem/presets/MenuItemField.tsx @@ -0,0 +1,86 @@ +import MenuItemRoot from '@components/MenuItem/layout/MenuItemRoot'; +import MenuItemRow from '@components/MenuItem/layout/MenuItemRow'; +import MenuItemTrailing from '@components/MenuItem/layout/MenuItemTrailing'; +import MenuItemLabel from '@components/MenuItem/leaves/text/MenuItemLabel'; +import MenuItemChevron from '@components/MenuItem/leaves/trailing/MenuItemChevron'; +import MenuItemRightLabel from '@components/MenuItem/leaves/trailing/MenuItemRightLabel'; + +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {callFunctionIfActionIsAllowed} from '@userActions/Session'; + +import type WithSentryLabel from '@src/types/utils/SentryLabel'; +import type WithTestID from '@src/types/utils/TestID'; + +import type {PropsWithChildren} from 'react'; +import type {GestureResponderEvent} from 'react-native'; + +import React from 'react'; +import {View} from 'react-native'; + +import MenuItemEmptyField from './MenuItemEmptyField'; + +type MenuItemFieldProps = PropsWithChildren & + WithSentryLabel & + WithTestID & { + /** Name of the field. Stands in for the value while the field is empty, and moves up into the label once it has one */ + label: string; + + /** The picked value. This is what decides if the field is empty or filled */ + value?: string; + + /** Whether to show the `Required` hint. Only reaches the screen while the field is empty, since a filled field cannot be missing */ + isRequired?: boolean; + + /** Function to fire when the row is pressed. Omit to make the row non-interactive, which also drops the chevron */ + onPress?: (event: GestureResponderEvent | KeyboardEvent) => void | Promise; + + /** Whether the menu item is disabled */ + isDisabled?: boolean; + }; + +/** The field MenuItem preset — a form field whose value the user picks on another screen */ +function MenuItemField({label, value, isRequired = false, onPress, isDisabled = false, sentryLabel, testID, children}: MenuItemFieldProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + + if (!value && !!onPress) { + return ( + + {isRequired && {translate('common.required')}} + + ); + } + + return ( + <> + + {label} + + + + {children} + {!!onPress && ( + + + + )} + + + + ); +} + +export default MenuItemField; diff --git a/src/components/VacationDelegateMenuItem.tsx b/src/components/VacationDelegateMenuItem.tsx index ce17608b5f91..297538859540 100644 --- a/src/components/VacationDelegateMenuItem.tsx +++ b/src/components/VacationDelegateMenuItem.tsx @@ -11,9 +11,8 @@ import React from 'react'; import UserAvatar from './Avatar/UserAvatar'; import MenuItem from './MenuItem'; -import {MENU_ITEM_DESCRIPTION_VARIANT} from './MenuItem/leaves/text/MenuItemDescription'; +import MenuItemField from './MenuItem/presets/MenuItemField'; import OfflineWithFeedback from './OfflineWithFeedback'; -import Text from './Text'; type VacationDelegateSectionProps = { /** Currently selected vacation delegate (if any) */ @@ -43,62 +42,34 @@ function VacationDelegateMenuItem({vacationDelegate, errors, pendingAction, onCl const icons = useMemoizedLazyExpensifyIcons(['FallbackAvatar']); const personalDetailsByLogin = usePersonalDetailsByLogin(); - const hasVacationDelegate = !!vacationDelegate?.delegate; const vacationDelegatePersonalDetails = personalDetailsByLogin[vacationDelegate?.delegate?.toLowerCase() ?? '']; const formattedDelegateLogin = formatPhoneNumber(vacationDelegatePersonalDetails?.login ?? ''); const fallbackVacationDelegateLogin = formattedDelegateLogin === '' ? vacationDelegate?.delegate : formattedDelegateLogin; - // With a delegate set, the row shows their name with their login underneath. Without one, the field's - // own label takes the description slot as a placeholder, so it renders at the standalone size instead. - const description = hasVacationDelegate ? fallbackVacationDelegateLogin : translate('common.vacationDelegate'); - const title = hasVacationDelegate ? (vacationDelegatePersonalDetails?.displayName ?? fallbackVacationDelegateLogin) : undefined; - - const delegateRow = ( - - - {hasVacationDelegate && ( - - - - )} - - {!!title && {title}} - {!!description && ( - - {description} - - )} - - - - - - - ); - - // The section heading and the offline/error feedback only exist once a delegate is set. - if (!hasVacationDelegate) { - return delegateRow; - } - return ( - <> - {translate('common.vacationDelegate')} - + - {delegateRow} - - + + + + + {vacationDelegatePersonalDetails?.displayName ?? fallbackVacationDelegateLogin ?? ''} + {!!fallbackVacationDelegateLogin && {fallbackVacationDelegateLogin}} + + + ); } diff --git a/src/pages/tasks/DynamicNewTaskPage.tsx b/src/pages/tasks/DynamicNewTaskPage.tsx index 27c4db1bb20b..93ac092183ac 100644 --- a/src/pages/tasks/DynamicNewTaskPage.tsx +++ b/src/pages/tasks/DynamicNewTaskPage.tsx @@ -1,16 +1,16 @@ +import AccountAvatar from '@components/Avatar/connected/AccountAvatar'; import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; import DisplayNames from '@components/DisplayNames'; -import type {DisplayNameWithTooltip} from '@components/DisplayNames/types'; import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton'; import FormHelpMessage from '@components/FormHelpMessage'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import MenuItem from '@components/MenuItem'; -import {MENU_ITEM_DESCRIPTION_VARIANT} from '@components/MenuItem/leaves/text/MenuItemDescription'; +import {useMenuItemConfig, useMenuItemInteraction} from '@components/MenuItem/MenuItemContext'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import ReportActionAvatars from '@components/ReportActionAvatars'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; -import Text from '@components/Text'; import useAncestors from '@hooks/useAncestors'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; @@ -21,6 +21,7 @@ import usePolicy from '@hooks/usePolicy'; import usePressLoading from '@hooks/usePressLoading'; import useReportAttributes from '@hooks/useReportAttributes'; import useSafeAreaPaddings from '@hooks/useSafeAreaPaddings'; +import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import {createTaskAndNavigate, dismissModalAndClearOutTaskInfo, getAssignee, getShareDestination, setShareDestinationValue} from '@libs/actions/Task'; @@ -39,77 +40,25 @@ import {isEmptyObject} from '@src/types/utils/EmptyObject'; import React, {useEffect, useRef, useState} from 'react'; import {View} from 'react-native'; -type TaskFieldRowProps = { - /** Name of the field, shown above the row once the field has a value */ - label: string; - - /** Avatars for the selected value. Falsy while the field is empty, which drops the leading cell */ - avatars?: React.ReactNode; - - /** Display name of the selected value. Absent while the field is empty */ - displayName?: string; - - /** Per-name tooltips for `displayName`. Falls back to plain text when empty */ - displayNamesWithTooltips?: DisplayNameWithTooltip[]; - - /** Supporting line under the title. With no `displayName` it carries the row on its own */ - description: string; - - /** Whether to show the `Required` hint in the trailing cell */ - shouldShowRequiredLabel?: boolean; - - /** Whether to show the trailing chevron */ - shouldShowChevron?: boolean; - - /** Function to fire when the row is pressed. Omit to make the row non-interactive */ - onPress?: () => void; -}; - /** - * One of the task's participant fields (assignee, share destination). Both render the same shape: the - * field name on top once a value is picked, then that value's avatar, name and secondary line. + * The leading avatar of the participant field below. A component of its own so that it renders inside + * `MenuItem.Root` and can read the row's interaction state. */ -function TaskFieldRow({label, avatars, displayName, displayNamesWithTooltips, description, shouldShowRequiredLabel = false, shouldShowChevron = true, onPress}: TaskFieldRowProps) { +function TaskFieldAvatar({reportID}: {reportID?: string}) { + const theme = useTheme(); const styles = useThemeStyles(); - const {translate} = useLocalize(); + const {isHovered, isPressed} = useMenuItemInteraction(); + const {isInteractive} = useMenuItemConfig(); - const hasValue = !!displayName; + const borderColor = isPressed ? theme.buttonHoveredBG : theme.hoverComponentBG; return ( - - {hasValue && ( - - {label} - - )} - - {!!avatars && {avatars}} - - {hasValue && - (displayNamesWithTooltips?.length ? ( - - - - ) : ( - {displayName} - ))} - {!!description && ( - {description} - )} - - {(shouldShowRequiredLabel || shouldShowChevron) && ( - - {shouldShowRequiredLabel && {translate('common.required')}} - {shouldShowChevron && } - - )} - - + ); } @@ -264,38 +213,56 @@ function DynamicNewTaskPage() { numberOfLinesTitle={2} titleStyle={styles.flex1} /> - - ) - } - displayName={assignee?.displayName} - displayNamesWithTooltips={assigneeTooltipDetails} - description={assignee?.displayName ? formatPhoneNumber(assignee?.subtitle) : translate('task.assignee')} + value={assignee?.displayName} onPress={() => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.NEW_TASK_ASSIGNEE.path))} - /> - + + {!!task?.assigneeAccountID && ( + - ) - } - displayName={shareDestination?.displayName} - displayNamesWithTooltips={shareDestination?.shouldUseFullTitleToDisplay ? undefined : shareDestination?.displayNamesWithTooltips} - description={shareDestination?.displayName ? (shareDestination.subtitle ?? '') : translate('common.share')} - shouldShowRequiredLabel={!shareDestination?.displayName} - shouldShowChevron={!task?.parentReportID} + )} + + + + + + {!!assignee?.subtitle && {formatPhoneNumber(assignee.subtitle)}} + + + Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.NEW_TASK_SHARE_DESTINATION.path))} - /> + > + + + + + {shareDestination?.shouldUseFullTitleToDisplay ? ( + {shareDestination.displayName} + ) : ( + + + + )} + {!!shareDestination?.subtitle && {shareDestination.subtitle}} + + From bb626886fb50ee934dcaaa2b4b9d81bea5b8d556 Mon Sep 17 00:00:00 2001 From: war-in Date: Fri, 21 Aug 2026 09:21:57 +0200 Subject: [PATCH 11/15] fix: remove unnecessary `MenuItemDescriptionVariantStyles` --- src/components/MenuItem/leaves/text/MenuItemDescription.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/components/MenuItem/leaves/text/MenuItemDescription.tsx b/src/components/MenuItem/leaves/text/MenuItemDescription.tsx index 1fce3ca2e0a3..4a161d6447ec 100644 --- a/src/components/MenuItem/leaves/text/MenuItemDescription.tsx +++ b/src/components/MenuItem/leaves/text/MenuItemDescription.tsx @@ -6,7 +6,6 @@ import useThemeStyles from '@hooks/useThemeStyles'; import variables from '@styles/variables'; -import type {StyleProp, TextStyle} from 'react-native'; import type {ValueOf} from 'type-fest'; import React from 'react'; @@ -21,8 +20,6 @@ const MENU_ITEM_DESCRIPTION_VARIANT = { type MenuItemDescriptionVariant = ValueOf; -type MenuItemDescriptionVariantStyles = Record>; - type MenuItemDescriptionProps = { /** Text to render as the description */ children: string | number; @@ -45,7 +42,7 @@ function MenuItemDescription({children, numberOfLines = 2, variant = MENU_ITEM_D useMenuItemAccessibilityLabel('description', String(children)); /** Typography applied on top of the shared supporting-label base, keyed by variant */ - const variantStyles: MenuItemDescriptionVariantStyles = { + const variantStyles = { [MENU_ITEM_DESCRIPTION_VARIANT.SUPPORTING]: styles.textLineHeightNormal, [MENU_ITEM_DESCRIPTION_VARIANT.PLACEHOLDER]: [StyleUtils.getFontSizeStyle(variables.fontSizeNormal), StyleUtils.getLineHeightStyle(variables.fontSizeNormalHeight)], }; From 03b670eb7c11a0b807fed5dab20fccea83b351a3 Mon Sep 17 00:00:00 2001 From: war-in Date: Fri, 21 Aug 2026 09:26:24 +0200 Subject: [PATCH 12/15] fix: don't filter when both exist --- src/components/MenuItem/presets/MenuItemEntity.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/MenuItem/presets/MenuItemEntity.tsx b/src/components/MenuItem/presets/MenuItemEntity.tsx index 7d8a08351dd1..212a7ce01d37 100644 --- a/src/components/MenuItem/presets/MenuItemEntity.tsx +++ b/src/components/MenuItem/presets/MenuItemEntity.tsx @@ -51,7 +51,7 @@ function MenuItemEntity({title, description, accountID, avatarSource, onPress, i isDisabled={isDisabled} sentryLabel={sentryLabel} testID={testID} - accessibilityLabel={[title, description].filter(Boolean).join(', ')} + accessibilityLabel={[title, description].join(', ')} > From 81218aec1daacc23cfdb48804782d92a472887f5 Mon Sep 17 00:00:00 2001 From: war-in Date: Fri, 21 Aug 2026 10:01:34 +0200 Subject: [PATCH 13/15] fix: move onyx data to loaders --- src/stories/MenuItemComparison.stories.tsx | 46 ++++++++++++---------- 1 file changed, 26 insertions(+), 20 deletions(-) diff --git a/src/stories/MenuItemComparison.stories.tsx b/src/stories/MenuItemComparison.stories.tsx index 7c3bfbb439e9..e47826d1efb2 100644 --- a/src/stories/MenuItemComparison.stories.tsx +++ b/src/stories/MenuItemComparison.stories.tsx @@ -37,6 +37,9 @@ import Onyx from 'react-native-onyx'; const story: Meta = { title: 'Components/MenuItemComparison', component: MenuItem, + // Storybook awaits loaders before the first render, so the ID-driven avatar cases always see the seeded data, + // and the writes only happen while this story is open. + loaders: [seedStoryOnyxData], }; const CARD_WIDTH = 360; @@ -46,26 +49,29 @@ const STORY_ACCOUNT_ID = 90210; const STORY_REPORT_ID = 'menuItemComparisonStoryReport'; const STORY_POLICY_ID = 'menuItemComparisonStoryPolicy'; -Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, { - [STORY_ACCOUNT_ID]: { - accountID: STORY_ACCOUNT_ID, - displayName: 'Alex Reed', - login: 'alex@example.com', - }, -}); - -Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${STORY_POLICY_ID}`, { - id: STORY_POLICY_ID, - name: 'Expensify Inc', -}); - -Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${STORY_REPORT_ID}`, { - reportID: STORY_REPORT_ID, - reportName: '#announce', - type: CONST.REPORT.TYPE.CHAT, - chatType: CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT, - policyID: STORY_POLICY_ID, -}); +/** Seeds the personal details, policy and report the ID-driven avatar cases read from */ +async function seedStoryOnyxData() { + await Promise.all([ + Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, { + [STORY_ACCOUNT_ID]: { + accountID: STORY_ACCOUNT_ID, + displayName: 'Alex Reed', + login: 'alex@example.com', + }, + }), + Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${STORY_POLICY_ID}`, { + id: STORY_POLICY_ID, + name: 'Expensify Inc', + }), + Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${STORY_REPORT_ID}`, { + reportID: STORY_REPORT_ID, + reportName: '#announce', + type: CONST.REPORT.TYPE.CHAT, + chatType: CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT, + policyID: STORY_POLICY_ID, + }), + ]); +} const STORY_TOOLTIP_DETAILS: DisplayNameWithTooltip[] = [{displayName: 'Alex Reed', accountID: STORY_ACCOUNT_ID, login: 'alex@example.com'}]; From be33b2b82f4c8eb8eb9c8a327068a2fdce80ca8d Mon Sep 17 00:00:00 2001 From: war-in Date: Fri, 21 Aug 2026 10:19:53 +0200 Subject: [PATCH 14/15] fix: remove unnecessary FallbackAvatar --- .../BeneficialOwnerInfo/subSteps/CompanyOwnersListUBO.tsx | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/pages/ReimbursementAccount/USD/BeneficialOwnerInfo/subSteps/CompanyOwnersListUBO.tsx b/src/pages/ReimbursementAccount/USD/BeneficialOwnerInfo/subSteps/CompanyOwnersListUBO.tsx index 1d6276b40c5f..46ec23ad9fa5 100644 --- a/src/pages/ReimbursementAccount/USD/BeneficialOwnerInfo/subSteps/CompanyOwnersListUBO.tsx +++ b/src/pages/ReimbursementAccount/USD/BeneficialOwnerInfo/subSteps/CompanyOwnersListUBO.tsx @@ -6,7 +6,6 @@ import MenuItemEntity from '@components/MenuItem/presets/MenuItemEntity'; import ScrollView from '@components/ScrollView'; import Text from '@components/Text'; -import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; @@ -45,7 +44,6 @@ type CompanyOwnersListUBOProps = { const REQUESTOR_PERSONAL_INFO_KEYS = INPUT_IDS.PERSONAL_INFO_STEP; function CompanyOwnersListUBO({isAnyoneElseUBO, isUserUBO, handleUBOsConfirmation, beneficialOwnerKeys, handleUBOEdit}: CompanyOwnersListUBOProps) { - const icons = useMemoizedLazyExpensifyIcons(['FallbackAvatar']); const {translate} = useLocalize(); const styles = useThemeStyles(); const {isOffline} = useNetwork(); @@ -90,10 +88,7 @@ function CompanyOwnersListUBO({isAnyoneElseUBO, isUserUBO, handleUBOsConfirmatio - + {`${requestorData.firstName} ${requestorData.lastName}`} From 11f72ab7165f69b6ded0c96346b7e48f1deedd77 Mon Sep 17 00:00:00 2001 From: war-in Date: Fri, 21 Aug 2026 10:28:38 +0200 Subject: [PATCH 15/15] fix: add default avatar handling --- src/components/MenuItem/presets/MenuItemEntity.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/MenuItem/presets/MenuItemEntity.tsx b/src/components/MenuItem/presets/MenuItemEntity.tsx index 212a7ce01d37..6b326366a051 100644 --- a/src/components/MenuItem/presets/MenuItemEntity.tsx +++ b/src/components/MenuItem/presets/MenuItemEntity.tsx @@ -9,6 +9,7 @@ import MenuItemTitle from '@components/MenuItem/leaves/text/MenuItemTitle'; import MenuItemChevron from '@components/MenuItem/leaves/trailing/MenuItemChevron'; import type {AvatarSource} from '@libs/UserAvatarUtils'; +import {getDefaultAvatarURL} from '@libs/UserAvatarUtils'; import {callFunctionIfActionIsAllowed} from '@userActions/Session'; @@ -56,7 +57,7 @@ function MenuItemEntity({title, description, accountID, avatarSource, onPress, i