From 81a97a82f3057185c396ed11b543e8419d90adcb Mon Sep 17 00:00:00 2001 From: war-in Date: Thu, 24 Sep 2026 17:18:24 +0200 Subject: [PATCH 01/15] refactor: always add announcements to accessibility label --- .../MenuItem/MenuItemAccessibilityContext.tsx | 150 +++++++++--------- .../MenuItem/layout/MenuItemRoot.tsx | 12 +- 2 files changed, 77 insertions(+), 85 deletions(-) diff --git a/src/components/MenuItem/MenuItemAccessibilityContext.tsx b/src/components/MenuItem/MenuItemAccessibilityContext.tsx index f0bde558cc9a..aea3c717d2c8 100644 --- a/src/components/MenuItem/MenuItemAccessibilityContext.tsx +++ b/src/components/MenuItem/MenuItemAccessibilityContext.tsx @@ -24,116 +24,108 @@ type MenuItemAccessibilityAnnouncement = ValueOf void; - - /** Removes the label registered under the given slot */ - unregisterLabel: (slot: MenuItemLabelSlot) => void; +/** Where sub-components hand `MenuItem.Root` text, one entry per key */ +type MenuItemAccessibilityRegistry = { + register: (key: TKey, text: string) => void; + unregister: (key: TKey) => void; +}; - /** Announces a fact about the row under a fixed key. Announcing the same fact twice announces it once */ - registerAnnouncement: (announcement: MenuItemAccessibilityAnnouncement, text: string) => void; +type MenuItemAccessibilityRegistries = { + /** Text leaves register their text here. Left out when the row names itself, so the leaves skip the work */ + label: MenuItemAccessibilityRegistry | undefined; - /** Stops announcing the given fact */ - unregisterAnnouncement: (announcement: MenuItemAccessibilityAnnouncement) => void; + /** Leaves announce facts about the row here. Always there, since the facts are read even on a row that names itself */ + announcement: MenuItemAccessibilityRegistry; }; -const MenuItemAccessibilityContext = createContext(undefined); +const MenuItemAccessibilityContext = createContext(undefined); -/** - * Contributes text to the label `MenuItem.Root` derives. The fixed slot key keeps the announced order - * deterministic (`top`, then `bottom`). No-op when `text` is empty or outside a `MenuItem.Root`. - */ -function useMenuItemAccessibilityLabel(slot: MenuItemLabelSlot, text: string | undefined) { - const actions = useContext(MenuItemAccessibilityContext); - const registerLabel = actions?.registerLabel; - const unregisterLabel = actions?.unregisterLabel; +/** Keeps `text` registered under `key` while the caller is mounted. No-op without a registry or text */ +function useRegistration(registry: MenuItemAccessibilityRegistry | undefined, key: TKey, text: string | undefined) { + const register = registry?.register; + const unregister = registry?.unregister; useEffect(() => { - if (!text || !registerLabel || !unregisterLabel) { + if (!text || !register || !unregister) { return; } - registerLabel(slot, text); - return () => unregisterLabel(slot); - }, [slot, text, registerLabel, unregisterLabel]); + register(key, text); + return () => unregister(key); + }, [key, text, register, unregister]); } -/** Contributes an already translated announcement about the row */ -function useMenuItemAccessibilityAnnouncement(announcement: MenuItemAccessibilityAnnouncement | undefined, text: string | undefined) { - const actions = useContext(MenuItemAccessibilityContext); - const registerAnnouncement = actions?.registerAnnouncement; - const unregisterAnnouncement = actions?.unregisterAnnouncement; +/** Contributes text to the label `MenuItem.Root` derives, announced in slot order (`top`, then `bottom`) */ +function useMenuItemAccessibilityLabel(slot: MenuItemLabelSlot, text: string | undefined) { + useRegistration(useContext(MenuItemAccessibilityContext)?.label, slot, text); +} - useEffect(() => { - if (!announcement || !text || !registerAnnouncement || !unregisterAnnouncement) { - return; - } - registerAnnouncement(announcement, text); - return () => unregisterAnnouncement(announcement); - }, [announcement, text, registerAnnouncement, unregisterAnnouncement]); +/** Contributes an already translated announcement about the row */ +function useMenuItemAccessibilityAnnouncement(announcement: MenuItemAccessibilityAnnouncement, text: string | undefined) { + useRegistration(useContext(MenuItemAccessibilityContext)?.announcement, announcement, text); } /** - * Small `key -> value` registry backed by an immutable `Map`. Writing back an unchanged value is a + * Small `key -> text` registry backed by an immutable `Map`. Writing back an unchanged text is a * no-op, so unrelated re-renders don't churn the map identity. */ -function useKeyedRegistry() { - const [entries, setEntries] = useState>(() => new Map()); - - const register = (key: TKey, value: TValue) => { - setEntries((prev) => { - if (prev.get(key) === value) { - return prev; - } - const next = new Map(prev); - next.set(key, value); - return next; - }); - }; - - const unregister = (key: TKey) => { - setEntries((prev) => { - if (!prev.has(key)) { - return prev; - } - const next = new Map(prev); - next.delete(key); - return next; - }); +function useKeyedRegistry() { + const [entries, setEntries] = useState>(() => new Map()); + + const registry: MenuItemAccessibilityRegistry = { + register: (key, text) => { + setEntries((prev) => { + if (prev.get(key) === text) { + return prev; + } + const next = new Map(prev); + next.set(key, text); + return next; + }); + }, + unregister: (key) => { + setEntries((prev) => { + if (!prev.has(key)) { + return prev; + } + const next = new Map(prev); + next.delete(key); + return next; + }); + }, }; - return {entries, register, unregister}; + return {entries, registry}; } /** - * Builds the row's accessibility label and hint out of what its sub-components registered, plus the - * value for `MenuItemAccessibilityContext.Provider` + * Builds the row's accessibility label and hint out of what its sub-components registered. An explicit + * `accessibilityLabel` replaces the derived one and switches the label registry off, but the announcements + * are still appended to it. */ -function useMenuItemAccessibility() { - // Text contributed by the text leaves, keyed by the line each one occupies - const {entries: labels, register: registerLabel, unregister: unregisterLabel} = useKeyedRegistry(); - - // Facts contributed by any child, keyed by the fact - const {entries: announcements, register: registerAnnouncement, unregister: unregisterAnnouncement} = useKeyedRegistry(); - - const accessibilityActions: MenuItemAccessibilityActions = {registerLabel, unregisterLabel, registerAnnouncement, unregisterAnnouncement}; +function useMenuItemAccessibility(accessibilityLabel?: string) { + const labels = useKeyedRegistry(); + const announcements = useKeyedRegistry(); - const derivedLabel = MENU_ITEM_LABEL_SLOTS.map((slot) => labels.get(slot)) + const derivedLabel = MENU_ITEM_LABEL_SLOTS.map((slot) => labels.entries.get(slot)) .filter(Boolean) .join(', '); - - const labelAnnouncements = MENU_ITEM_LABEL_ANNOUNCEMENT_SLOTS.map((announcement) => announcements.get(announcement)).filter(Boolean); - const hintAnnouncements = MENU_ITEM_HINT_ANNOUNCEMENT_SLOTS.map((announcement) => announcements.get(announcement)).filter(Boolean); + const labelAnnouncements = MENU_ITEM_LABEL_ANNOUNCEMENT_SLOTS.map((announcement) => announcements.entries.get(announcement)).filter(Boolean); + const hintAnnouncements = MENU_ITEM_HINT_ANNOUNCEMENT_SLOTS.map((announcement) => announcements.entries.get(announcement)).filter(Boolean); // Keeps the hints out of the name on native, and folds them into it on the web - const {accessibilityLabel, accessibilityHint} = getContextMenuAccessibilityProps({ - accessibilityLabel: [derivedLabel, ...labelAnnouncements].filter(Boolean).join('. '), + const accessibilityProps = getContextMenuAccessibilityProps({ + accessibilityLabel: [accessibilityLabel ?? derivedLabel, ...labelAnnouncements].filter(Boolean).join('. '), contextMenuHint: hintAnnouncements.join('. ') || undefined, }); - return {accessibilityLabel, accessibilityHint, accessibilityActions}; + const registries: MenuItemAccessibilityRegistries = { + label: accessibilityLabel === undefined ? labels.registry : undefined, + announcement: announcements.registry, + }; + + return {...accessibilityProps, registries}; } -export default MenuItemAccessibilityContext; export type {MenuItemLabelSlot}; -export {MENU_ITEM_ACCESSIBILITY_ANNOUNCEMENT, useMenuItemAccessibilityLabel, useMenuItemAccessibilityAnnouncement, useMenuItemAccessibility}; +export default MenuItemAccessibilityContext; +export {MENU_ITEM_ACCESSIBILITY_ANNOUNCEMENT, useMenuItemAccessibility, useMenuItemAccessibilityAnnouncement, useMenuItemAccessibilityLabel}; diff --git a/src/components/MenuItem/layout/MenuItemRoot.tsx b/src/components/MenuItem/layout/MenuItemRoot.tsx index ec77be902f4c..89d742349c7c 100644 --- a/src/components/MenuItem/layout/MenuItemRoot.tsx +++ b/src/components/MenuItem/layout/MenuItemRoot.tsx @@ -35,9 +35,9 @@ type MenuItemRootProps = PropsWithChildren & isDisabled?: boolean; /** - * Pre-computed accessibility label. When provided, `Root` uses it directly instead of - * deriving the label from registered `Title`/`Description` children. Presets that know - * their text statically should pass it. + * Pre-computed accessibility label. When provided, `Root` uses it instead of deriving the label + * from the text leaves. Announcements such as "opens in a new tab" are still appended. + * Presets that know their text statically should pass it. */ accessibilityLabel?: string; }; @@ -50,7 +50,7 @@ function MenuItemRoot({children, onPress, isDisabled = false, sentryLabel, testI const {shouldUseNarrowLayout} = useResponsiveLayout(); const isInteractive = !!onPress; - const {accessibilityLabel: derivedAccessibilityLabel, accessibilityHint, accessibilityActions} = useMenuItemAccessibility(); + const {accessibilityLabel: rowAccessibilityLabel, accessibilityHint, registries: accessibilityRegistries} = useMenuItemAccessibility(accessibilityLabel); const {handler: registeredSecondaryInteraction, register: registerSecondaryInteraction} = useMenuItemSecondaryInteractionRegistry(); useRemoveNonInteractiveClickHandler(pressableRef, isInteractive); @@ -101,7 +101,7 @@ function MenuItemRoot({children, onPress, isDisabled = false, sentryLabel, testI disabled={isDisabled} ref={pressableRef} role={isInteractive ? CONST.ROLE.BUTTON : undefined} - accessibilityLabel={accessibilityLabel ?? derivedAccessibilityLabel} + accessibilityLabel={rowAccessibilityLabel} accessibilityHint={accessibilityHint} accessible tabIndex={isInteractive ? 0 : -1} @@ -109,7 +109,7 @@ function MenuItemRoot({children, onPress, isDisabled = false, sentryLabel, testI testID={testID} > {({pressed}) => ( - + Date: Thu, 24 Sep 2026 17:46:03 +0200 Subject: [PATCH 02/15] refactor: migrate to MenuItem composable API --- .../sections/AttendeeField.tsx | 86 +++++++++++-------- src/pages/DynamicReportDetailsPage.tsx | 37 ++++---- .../TypeBusiness/BusinessTypePicker/index.tsx | 36 +++++--- .../subSteps/TypeBusiness/TypeBusiness.tsx | 2 +- src/pages/Travel/CarTripDetails.tsx | 34 ++++---- src/pages/Travel/FlightTripDetails.tsx | 85 ++++++++++-------- src/pages/Travel/HotelTripDetails.tsx | 34 ++++---- src/pages/Travel/TrainTripDetails.tsx | 73 +++++++++------- .../Wallet/ExpensifyCardPage/index.tsx | 12 ++- ...namicWorkspaceExpensifyCardDetailsPage.tsx | 12 ++- .../MergeATSFinalApproverMenuItem.tsx | 49 +++++------ 11 files changed, 250 insertions(+), 210 deletions(-) diff --git a/src/components/MoneyRequestConfirmationList/sections/AttendeeField.tsx b/src/components/MoneyRequestConfirmationList/sections/AttendeeField.tsx index 294b0b4e6a3a..2fb3bf22ba82 100644 --- a/src/components/MoneyRequestConfirmationList/sections/AttendeeField.tsx +++ b/src/components/MoneyRequestConfirmationList/sections/AttendeeField.tsx @@ -1,4 +1,6 @@ -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import FormHelpMessage from '@components/FormHelpMessage'; +import MenuItem from '@components/MenuItem'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import {usePersonalDetails} from '@components/OnyxListItemProvider'; import UserPills from '@components/UserPills'; @@ -12,6 +14,8 @@ import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/crea import Navigation from '@libs/Navigation/Navigation'; import {getAttendeesListDisplayString} from '@libs/TransactionUtils'; +import {callFunctionIfActionIsAllowed} from '@userActions/Session'; + import CONST from '@src/CONST'; import type {IOUAction, IOUType} from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; @@ -49,41 +53,55 @@ function AttendeeField({formattedAmountPerAttendee, isReadOnly, transactionID, a const iouAttendees = enrichAndSortAttendees(rawIouAttendees, loginToAccountIDMap, personalDetailsList, localeCompare); return ( - 1 && formattedAmountPerAttendee ? `· ${formattedAmountPerAttendee} ${translate('common.perPerson')}` : '' - }`} - descriptionTextStyle={styles.textLabelSupportingNormal} - titleComponent={ - Array.isArray(iouAttendees) ? ( - ({ - avatar: a?.avatarUrl, - displayName: a?.displayName ?? a?.email ?? '', - accountID: a?.accountID, - email: a?.email, - }))} - maxVisible={isReadOnly ? iouAttendees.length : undefined} - /> - ) : undefined - } - style={[styles.moneyRequestMenuItem]} - titleStyle={styles.flex1} - onPress={() => { - if (!transactionID) { - return; - } + { + if (!transactionID) { + return; + } - Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.MONEY_REQUEST_ATTENDEE.getRoute(action, iouType, transactionID, reportID))); - }} - interactive={!isReadOnly} - brickRoadIndicator={shouldDisplayAttendeesError ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} - errorText={shouldDisplayAttendeesError ? translate(formError as TranslationPaths) : ''} + Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.MONEY_REQUEST_ATTENDEE.getRoute(action, iouType, transactionID, reportID))); + }) + } + accessibilityLabel={`${translate('iou.attendees')}, ${Array.isArray(iouAttendees) ? getAttendeesListDisplayString(iouAttendees) : ''}`} sentryLabel={CONST.SENTRY_LABEL.REQUEST_CONFIRMATION_LIST.ATTENDEES_FIELD} - /> + > + + 1 && formattedAmountPerAttendee ? `· ${formattedAmountPerAttendee} ${translate('common.perPerson')}` : '' + }`} + > + {Array.isArray(iouAttendees) ? ( + ({ + avatar: a?.avatarUrl, + displayName: a?.displayName ?? a?.email ?? '', + accountID: a?.accountID, + email: a?.email, + }))} + maxVisible={isReadOnly ? iouAttendees.length : undefined} + /> + ) : undefined} + + {(shouldDisplayAttendeesError || !isReadOnly) && ( + + {shouldDisplayAttendeesError && } + {!isReadOnly && } + + )} + + {shouldDisplayAttendeesError && ( + + )} + ); } diff --git a/src/pages/DynamicReportDetailsPage.tsx b/src/pages/DynamicReportDetailsPage.tsx index 133d985fe5cc..000e9a983afc 100644 --- a/src/pages/DynamicReportDetailsPage.tsx +++ b/src/pages/DynamicReportDetailsPage.tsx @@ -855,26 +855,23 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report const shouldShowEditableTitleField = caseID !== CASES.MONEY_REQUEST && canEditReportTitle(report, policy, currentUserAccountID, rules); const nameSectionFurtherDetailsContent = ( - - } - description={translate('threads.from')} - descriptionTextStyle={[styles.mutedNormalTextLabel, styles.mb1]} - shouldCheckActionAllowedOnPress={false} - /> + + + + + + + ); const nameSectionTitleField = ( diff --git a/src/pages/ReimbursementAccount/USD/BusinessInfo/subSteps/TypeBusiness/BusinessTypePicker/index.tsx b/src/pages/ReimbursementAccount/USD/BusinessInfo/subSteps/TypeBusiness/BusinessTypePicker/index.tsx index 147d036c1716..fd85cf05bb02 100644 --- a/src/pages/ReimbursementAccount/USD/BusinessInfo/subSteps/TypeBusiness/BusinessTypePicker/index.tsx +++ b/src/pages/ReimbursementAccount/USD/BusinessInfo/subSteps/TypeBusiness/BusinessTypePicker/index.tsx @@ -1,8 +1,12 @@ -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import FormHelpMessage from '@components/FormHelpMessage'; +import MenuItem from '@components/MenuItem'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; +import {callFunctionIfActionIsAllowed} from '@userActions/Session'; + import CONST from '@src/CONST'; import type {StyleProp, ViewStyle} from 'react-native'; @@ -57,20 +61,26 @@ function BusinessTypePicker({errorText = '', value = '', wrapperStyle, onInputCh }; const title = value ? translate(`businessInfoStep.incorporationType.${value as IncorporationType}`) : ''; - const descStyle = title.length === 0 ? styles.textNormal : null; return ( - - + + + + {!!errorText && } + + + {!!errorText && ( + + )} + ); diff --git a/src/pages/Travel/CarTripDetails.tsx b/src/pages/Travel/CarTripDetails.tsx index 1df3a9fadf6e..8fb64ce81bdd 100644 --- a/src/pages/Travel/CarTripDetails.tsx +++ b/src/pages/Travel/CarTripDetails.tsx @@ -88,24 +88,22 @@ function CarTripDetails({reservation, personalDetails}: CarTripDetailsProps) { )} {!!displayName && ( - - } - /> + + + + + + + )} ); diff --git a/src/pages/Travel/FlightTripDetails.tsx b/src/pages/Travel/FlightTripDetails.tsx index 04dd7bad9a6f..81576d2561a5 100644 --- a/src/pages/Travel/FlightTripDetails.tsx +++ b/src/pages/Travel/FlightTripDetails.tsx @@ -1,3 +1,4 @@ +import FormHelpMessage from '@components/FormHelpMessage'; import Icon from '@components/Icon'; import MenuItem from '@components/MenuItem'; import MenuItemField from '@components/MenuItem/presets/MenuItemField'; @@ -46,6 +47,8 @@ function FlightTripDetails({reservation, prevReservation, personalDetails}: Flig const startDate = DateUtils.getFormattedTransportDateAndHour(new Date(reservation.start.date), dateFnsLocale); const endDate = DateUtils.getFormattedTransportDateAndHour(new Date(reservation.end.date), dateFnsLocale); + const takeOffLocation = `${reservation.start.longName} (${reservation.start.shortName})${reservation.arrivalGate?.terminal ? `, ${reservation.arrivalGate?.terminal}` : ''}`; + const landingLocation = `${reservation.end.longName} (${reservation.end.shortName})`; const prevFlightEndDate = prevReservation?.end.date; const layover = prevFlightEndDate && DateUtils.getFormattedDurationBetweenDates(translate, new Date(prevFlightEndDate), new Date(reservation.start.date)); @@ -87,25 +90,37 @@ function FlightTripDetails({reservation, prevReservation, personalDetails}: Flig - {startDate.hour}} - helperText={`${reservation.start.longName} (${reservation.start.shortName})${reservation.arrivalGate?.terminal ? `, ${reservation.arrivalGate?.terminal}` : ''}`} - helperTextStyle={[styles.pb3, styles.mtn2]} - interactive={false} - copyValue={`${startDate.hour} ${reservation.start.longName} (${reservation.start.shortName})${reservation.arrivalGate?.terminal ? `, ${reservation.arrivalGate?.terminal}` : ''}`} - copyable + + + + {startDate.hour} + + + + + + + - {endDate.hour}} - helperText={`${reservation.end.longName} (${reservation.end.shortName})`} - helperTextStyle={[styles.pb3, styles.mtn2]} - interactive={false} - copyValue={`${endDate.hour} ${reservation.end.longName} (${reservation.end.shortName})`} - copyable + + + + {endDate.hour} + + + + + + + @@ -142,24 +157,22 @@ function FlightTripDetails({reservation, prevReservation, personalDetails}: Flig )} {!!displayName && ( - - } - /> + + + + + + + )} ); diff --git a/src/pages/Travel/HotelTripDetails.tsx b/src/pages/Travel/HotelTripDetails.tsx index d2d65efd884a..6f7b599db180 100644 --- a/src/pages/Travel/HotelTripDetails.tsx +++ b/src/pages/Travel/HotelTripDetails.tsx @@ -89,24 +89,22 @@ function HotelTripDetails({reservation, personalDetails}: HotelTripDetailsProps) )} {!!displayName && ( - - } - /> + + + + + + + )} ); diff --git a/src/pages/Travel/TrainTripDetails.tsx b/src/pages/Travel/TrainTripDetails.tsx index e9b053e872ee..4939f16d26e2 100644 --- a/src/pages/Travel/TrainTripDetails.tsx +++ b/src/pages/Travel/TrainTripDetails.tsx @@ -1,3 +1,4 @@ +import FormHelpMessage from '@components/FormHelpMessage'; import MenuItem from '@components/MenuItem'; import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; @@ -52,21 +53,31 @@ function TrainTripDetails({reservation, personalDetails}: TrainTripDetailsProps) value={startDate.date} /> - {startDate.hour}} - helperText={formatTransitLocationLabel(reservation.start)} - helperTextStyle={[styles.pb3, styles.mtn2]} - interactive={false} + + + + {startDate.hour} + + + + - {endDate.hour}} - helperText={formatTransitLocationLabel(reservation.end)} - helperTextStyle={[styles.pb3, styles.mtn2]} - interactive={false} + + + + {endDate.hour} + + + + @@ -97,24 +108,22 @@ function TrainTripDetails({reservation, personalDetails}: TrainTripDetailsProps) )} {!!displayName && ( - - } - /> + + + + + + + )} ); diff --git a/src/pages/settings/Wallet/ExpensifyCardPage/index.tsx b/src/pages/settings/Wallet/ExpensifyCardPage/index.tsx index 673e53da450a..4012fb39fda4 100644 --- a/src/pages/settings/Wallet/ExpensifyCardPage/index.tsx +++ b/src/pages/settings/Wallet/ExpensifyCardPage/index.tsx @@ -444,13 +444,11 @@ function ExpensifyCardPage({route}: ExpensifyCardPageProps) { )} {shouldShowSpendRulesSummary && ( - + + + {spendRulesTitleComponent} + + )} {spendRulesSummary.length > 0 && ( - + + + {spendRulesTitleComponent} + + )} diff --git a/src/pages/workspace/recruiting/approver/MergeATSFinalApproverMenuItem.tsx b/src/pages/workspace/recruiting/approver/MergeATSFinalApproverMenuItem.tsx index 4e79dce7a662..948b2b8ed911 100644 --- a/src/pages/workspace/recruiting/approver/MergeATSFinalApproverMenuItem.tsx +++ b/src/pages/workspace/recruiting/approver/MergeATSFinalApproverMenuItem.tsx @@ -1,11 +1,13 @@ -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import MenuItem from '@components/MenuItem'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import UserPills from '@components/UserPills'; import usePersonalDetailByLogin from '@hooks/usePersonalDetailByLogin'; -import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; +import {callFunctionIfActionIsAllowed} from '@userActions/Session'; + import ROUTES from '@src/ROUTES'; import React from 'react'; @@ -21,32 +23,31 @@ type MergeATSFinalApproverMenuItemProps = { }; function MergeATSFinalApproverMenuItem({policyID, description}: MergeATSFinalApproverMenuItemProps) { - const styles = useThemeStyles(); const {finalApprover} = useMergeATSApprovalDraftState(policyID); const finalApproverDetails = usePersonalDetailByLogin(finalApprover); return ( - - ) : undefined - } - titleStyle={styles.flex1} - onPress={() => Navigation.navigate(ROUTES.WORKSPACE_RECRUITING_MERGE_FINAL_APPROVER.getRoute(policyID))} - /> + Navigation.navigate(ROUTES.WORKSPACE_RECRUITING_MERGE_FINAL_APPROVER.getRoute(policyID)))}> + + + {!!finalApprover && ( + + )} + + + + + + ); } From 026b6a9f2692792579966412f186f07089c4529d Mon Sep 17 00:00:00 2001 From: war-in Date: Thu, 24 Sep 2026 17:49:35 +0200 Subject: [PATCH 03/15] test: add accessibility tests --- .../MenuItem/MenuItemAccessibilityTest.tsx | 164 ++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 tests/ui/components/MenuItem/MenuItemAccessibilityTest.tsx diff --git a/tests/ui/components/MenuItem/MenuItemAccessibilityTest.tsx b/tests/ui/components/MenuItem/MenuItemAccessibilityTest.tsx new file mode 100644 index 000000000000..e9865e925311 --- /dev/null +++ b/tests/ui/components/MenuItem/MenuItemAccessibilityTest.tsx @@ -0,0 +1,164 @@ +import {render, screen} from '@testing-library/react-native'; + +import {LocaleContextProvider} from '@components/LocaleContextProvider'; +import MenuItem from '@components/MenuItem'; +import MenuItemAccessibilityContext from '@components/MenuItem/MenuItemAccessibilityContext'; + +import CONST from '@src/CONST'; + +import type {ProfilerOnRenderCallback} from 'react'; + +import React, {Profiler, useContext} from 'react'; + +jest.mock('@components/ImageSVG', () => () => null); + +jest.mock('@hooks/useLazyAsset', () => ({ + useMemoizedLazyExpensifyIcons: jest.fn(() => ({ + ArrowRight: () => null, + DotIndicator: () => null, + })), +})); + +const NAME = 'Passenger'; +const VALUE = 'John'; +const ROW_LABEL = 'Passenger John'; +const REVIEW_REQUIRED = 'Your review is required'; + +type RowProps = { + accessibilityLabel?: string; + shouldShowBrickRoadIndicator?: boolean; +}; + +function Row({accessibilityLabel, shouldShowBrickRoadIndicator = false}: RowProps) { + return ( + + + + {NAME} + {VALUE} + + {shouldShowBrickRoadIndicator && ( + + + + )} + + + ); +} + +/** Renders the row and counts the commits it takes after mounting, i.e. the re-renders registrations cause */ +function renderCountingUpdates(props: RowProps) { + let updates = 0; + const onRender: ProfilerOnRenderCallback = (id, phase) => { + if (phase !== 'update') { + return; + } + updates++; + }; + + render( + + + + + , + ); + + return () => updates; +} + +describe('MenuItem accessibility', () => { + it('derives the label from the text leaves when the row does not name itself', async () => { + // Given a row with no explicit label + render( + + + , + ); + + // Then the label is built from the name and the value, top line first + expect(await screen.findByLabelText(`${NAME}, ${VALUE}`)).toBeOnTheScreen(); + }); + + it('still announces facts about a row that names itself', async () => { + // Given a row that passes its own label, e.g. because its value is an element the text leaves can't read + render( + + + , + ); + + // Then the brick road indicator's announcement is appended to that label, as legacy MenuItem does + expect(await screen.findByLabelText(`${ROW_LABEL}. ${REVIEW_REQUIRED}`)).toBeOnTheScreen(); + }); + + describe('re-renders', () => { + it('does not re-render the leaves when Root picks up their registrations', async () => { + // Given a leaf-like probe that reads the accessibility context, in a row that derives its label + const contextValues: unknown[] = []; + function ContextProbe() { + contextValues.push(useContext(MenuItemAccessibilityContext)); + return null; + } + render( + + + + + {NAME} + {VALUE} + + + + + , + ); + + // When Root has re-rendered with the derived label + await screen.findByLabelText(`${NAME}, ${VALUE}`); + + // Then the context value kept its identity, so the probe rendered only once + expect(contextValues).toHaveLength(1); + }); + + it('costs a row that derives its label a single re-render', async () => { + // Given a row whose label comes from its text leaves + const getUpdates = renderCountingUpdates({}); + + // When the label has been derived + await screen.findByLabelText(`${NAME}, ${VALUE}`); + + // Then both leaves registered in the same commit, so Root re-rendered once + expect(getUpdates()).toBe(1); + }); + + it('costs a row that names itself nothing when it has nothing to announce', async () => { + // Given a row that names itself, which is what the presets render + const getUpdates = renderCountingUpdates({accessibilityLabel: ROW_LABEL}); + + // When it has mounted + await screen.findByLabelText(ROW_LABEL); + + // Then its text leaves skipped registering, so Root never re-rendered + expect(getUpdates()).toBe(0); + }); + + it('costs a row that names itself a single re-render to pick up an announcement', async () => { + // Given a row that names itself and shows a brick road indicator + const getUpdates = renderCountingUpdates({accessibilityLabel: ROW_LABEL, shouldShowBrickRoadIndicator: true}); + + // When the announcement has landed + await screen.findByLabelText(`${ROW_LABEL}. ${REVIEW_REQUIRED}`); + + // Then only the announcement registered, and the text leaves stayed out of it + expect(getUpdates()).toBe(1); + }); + }); +}); From 240d9f9706b58f06097e2efb08ff91c6d7b5139d Mon Sep 17 00:00:00 2001 From: war-in Date: Fri, 25 Sep 2026 11:02:10 +0200 Subject: [PATCH 04/15] feat: split MenuItemIcon into separate narrow variant --- src/components/MenuItem/index.ts | 2 + .../MenuItem/leaves/leading/MenuItemIcon.tsx | 33 ++----------- .../leaves/leading/MenuItemIconNarrow.tsx | 21 ++++++++ .../leaves/leading/base/BaseMenuItemIcon.tsx | 48 +++++++++++++++++++ 4 files changed, 76 insertions(+), 28 deletions(-) create mode 100644 src/components/MenuItem/leaves/leading/MenuItemIconNarrow.tsx create mode 100644 src/components/MenuItem/leaves/leading/base/BaseMenuItemIcon.tsx diff --git a/src/components/MenuItem/index.ts b/src/components/MenuItem/index.ts index a11728e915c9..4930da43cd46 100644 --- a/src/components/MenuItem/index.ts +++ b/src/components/MenuItem/index.ts @@ -40,6 +40,7 @@ import MenuItemFieldValueHTML from './leaves/content/MenuItemFieldValueHTML'; import MenuItemLabel from './leaves/content/MenuItemLabel'; import MenuItemTitle from './leaves/content/MenuItemTitle'; import MenuItemIcon from './leaves/leading/MenuItemIcon'; +import MenuItemIconNarrow from './leaves/leading/MenuItemIconNarrow'; import MenuItemHelpText from './leaves/MenuItemHelpText'; import MenuItemBrickRoadIndicator from './leaves/trailing/icons/MenuItemBrickRoadIndicator'; import MenuItemChevron from './leaves/trailing/icons/MenuItemChevron'; @@ -56,6 +57,7 @@ const MenuItem = Object.assign(LegacyMenuItem, { Content: MenuItemContent, Trailing: MenuItemTrailing, Icon: MenuItemIcon, + IconNarrow: MenuItemIconNarrow, Label: MenuItemLabel, Title: MenuItemTitle, Description: MenuItemDescription, diff --git a/src/components/MenuItem/leaves/leading/MenuItemIcon.tsx b/src/components/MenuItem/leaves/leading/MenuItemIcon.tsx index 698fe2a1a7d9..4df05c065256 100644 --- a/src/components/MenuItem/leaves/leading/MenuItemIcon.tsx +++ b/src/components/MenuItem/leaves/leading/MenuItemIcon.tsx @@ -1,42 +1,19 @@ -import Icon from '@components/Icon'; -import useIsCompactPopover from '@components/MenuItem/hooks/useIsCompactPopover'; -import {useMenuItemConfig, useMenuItemInteraction} from '@components/MenuItem/MenuItemContext'; - -import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; -import getButtonState from '@libs/getButtonState'; - -import type IconAsset from '@src/types/utils/IconAsset'; - import React from 'react'; -type MenuItemIconProps = { - src: IconAsset; -}; +import type {MenuItemIconProps} from './base/BaseMenuItemIcon'; + +import BaseMenuItemIcon from './base/BaseMenuItemIcon'; /** An icon glyph, filled from the row's interaction state */ function MenuItemIcon({src}: MenuItemIconProps) { const styles = useThemeStyles(); - const StyleUtils = useStyleUtils(); - const {isDisabled, isInteractive} = useMenuItemConfig(); - const {isHovered, isPressed} = useMenuItemInteraction(); - const isCompactPopover = useIsCompactPopover(); - - const iconFill = StyleUtils.getIconFillColor({ - buttonState: getButtonState({isActive: isHovered, isPressed, isDisabled, isInteractive}), - isMenuIcon: true, - isPane: true, - }); return ( - ); } diff --git a/src/components/MenuItem/leaves/leading/MenuItemIconNarrow.tsx b/src/components/MenuItem/leaves/leading/MenuItemIconNarrow.tsx new file mode 100644 index 000000000000..d825f039860b --- /dev/null +++ b/src/components/MenuItem/leaves/leading/MenuItemIconNarrow.tsx @@ -0,0 +1,21 @@ +import useThemeStyles from '@hooks/useThemeStyles'; + +import React from 'react'; + +import type {MenuItemIconProps} from './base/BaseMenuItemIcon'; + +import BaseMenuItemIcon from './base/BaseMenuItemIcon'; + +/** An icon glyph that takes only its own width */ +function MenuItemIconNarrow({src}: MenuItemIconProps) { + const styles = useThemeStyles(); + + return ( + + ); +} + +export default MenuItemIconNarrow; diff --git a/src/components/MenuItem/leaves/leading/base/BaseMenuItemIcon.tsx b/src/components/MenuItem/leaves/leading/base/BaseMenuItemIcon.tsx new file mode 100644 index 000000000000..de99bf6c835b --- /dev/null +++ b/src/components/MenuItem/leaves/leading/base/BaseMenuItemIcon.tsx @@ -0,0 +1,48 @@ +import Icon from '@components/Icon'; +import {useMenuItemConfig, useMenuItemInteraction} from '@components/MenuItem/MenuItemContext'; + +import useStyleUtils from '@hooks/useStyleUtils'; + +import getButtonState from '@libs/getButtonState'; + +import type IconAsset from '@src/types/utils/IconAsset'; + +import type {StyleProp, ViewStyle} from 'react-native'; + +import React from 'react'; + +type MenuItemIconProps = { + src: IconAsset; +}; + +type BaseMenuItemIconProps = MenuItemIconProps & { + /** The slot the glyph sits in, which is all the icon leaves differ by */ + style: StyleProp; +}; + +/** Base of the icon leaves: a glyph filled from the row's interaction state */ +function BaseMenuItemIcon({src, style}: BaseMenuItemIconProps) { + const StyleUtils = useStyleUtils(); + const {isDisabled, isInteractive} = useMenuItemConfig(); + const {isHovered, isPressed} = useMenuItemInteraction(); + + const iconFill = StyleUtils.getIconFillColor({ + buttonState: getButtonState({isActive: isHovered, isPressed, isDisabled, isInteractive}), + isMenuIcon: true, + isPane: true, + }); + + return ( + + ); +} + +export default BaseMenuItemIcon; +export type {MenuItemIconProps}; From b775de97c31bf81f71b9cbad71bcce52273ea7e3 Mon Sep 17 00:00:00 2001 From: war-in Date: Fri, 25 Sep 2026 11:02:29 +0200 Subject: [PATCH 05/15] refactor: migrate to the narrow icon --- .../Search/FilterDropdowns/DisplayPopup.tsx | 26 +++++++++----- .../Waypoints/DistanceCounter.tsx | 34 ++++++++----------- 2 files changed, 33 insertions(+), 27 deletions(-) diff --git a/src/components/Search/FilterDropdowns/DisplayPopup.tsx b/src/components/Search/FilterDropdowns/DisplayPopup.tsx index 55a52d0474c7..7e454063d78b 100644 --- a/src/components/Search/FilterDropdowns/DisplayPopup.tsx +++ b/src/components/Search/FilterDropdowns/DisplayPopup.tsx @@ -1,5 +1,5 @@ import CompactMenuContext from '@components/CompactMenuContext'; -import MenuItemAction from '@components/MenuItem/presets/MenuItemAction'; +import MenuItem from '@components/MenuItem'; import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import ScrollView from '@components/ScrollView'; import useUpdateFilterQuery from '@components/Search/hooks/useUpdateFilterQuery'; @@ -15,6 +15,8 @@ import {close} from '@libs/actions/Modal'; import Navigation from '@libs/Navigation/Navigation'; import {getGroupBySections, getSearchColumnTranslationKey, getValidGroupBy, getViewOptions} from '@libs/SearchUIUtils'; +import {callFunctionIfActionIsAllowed} from '@userActions/Session'; + import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -43,7 +45,7 @@ type DisplayPopupProps = { function DisplayPopup({queryJSON, searchResults, closeOverlay, onSort}: DisplayPopupProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); - const {isLargeScreenWidth} = useResponsiveLayout(); + const {isLargeScreenWidth, isSmallScreenWidth} = useResponsiveLayout(); // eslint-disable-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const expensifyIcons = useMemoizedLazyExpensifyIcons(['Columns']); const {updateFilterQueryParams} = useUpdateFilterQuery(queryJSON); const [searchAdvancedFilters = getEmptyObject()] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM); @@ -121,15 +123,23 @@ function DisplayPopup({queryJSON, searchResults, closeOverlay, onSort}: DisplayP )} {shouldShowColumnsButton && ( - { + { closeOverlay(); openSearchColumns(); - }} + })} + accessibilityLabel={translate('search.editColumns')} sentryLabel={CONST.SENTRY_LABEL.SEARCH.COLUMNS_BUTTON} - /> + > + + + {isSmallScreenWidth ? : } + + + {translate('search.editColumns')} + + + )} diff --git a/src/pages/iou/request/step/IOURequestStepDistanceGPS/Waypoints/DistanceCounter.tsx b/src/pages/iou/request/step/IOURequestStepDistanceGPS/Waypoints/DistanceCounter.tsx index 5c7ec142e0a9..babdfae8ca5a 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceGPS/Waypoints/DistanceCounter.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceGPS/Waypoints/DistanceCounter.tsx @@ -1,17 +1,15 @@ -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import MenuItem from '@components/MenuItem'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import Text from '@components/Text'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; -import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import DistanceRequestUtils from '@libs/DistanceRequestUtils'; import {getEffectiveDistance} from '@libs/GPSDraftDetailsUtils'; -import {fontScale} from '@styles/typography'; - import ONYXKEYS from '@src/ONYXKEYS'; import type {Unit} from '@src/types/onyx/Policy'; @@ -24,27 +22,25 @@ function DistanceCounter({unit}: DistanceCounterProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); const icons = useMemoizedLazyExpensifyIcons(['Crosshair']); - const StyleUtils = useStyleUtils(); const [gpsDraftDetails] = useOnyx(ONYXKEYS.GPS_DRAFT_DETAILS); const distance = DistanceRequestUtils.getRoundedDistanceInUnits(getEffectiveDistance(gpsDraftDetails), unit); return ( - - {distance} - {` ${unit}`} - - } - style={[styles.ph0]} - icon={icons.Crosshair} - shouldIconUseAutoWidthStyle - descriptionTextStyle={StyleUtils.getFontSizeStyle(fontScale.label)} - /> + + + + + + + + {distance} + {` ${unit}`} + + + + ); } From 2958fdf3f48abc635440305697eae129a8c284a2 Mon Sep 17 00:00:00 2001 From: war-in Date: Fri, 25 Sep 2026 11:07:45 +0200 Subject: [PATCH 06/15] refactor: migrate MenuItems with `descriptionTextStyle` --- .../Waypoints/index.tsx | 5 +- .../ChangeBillingCurrency/index.tsx | 10 ++-- .../PolicyDistanceRateDetailsPage.tsx | 56 +++++++------------ .../WorkspaceAutoReportingFrequencyPage.tsx | 12 ++-- 4 files changed, 29 insertions(+), 54 deletions(-) diff --git a/src/pages/iou/request/step/IOURequestStepDistanceGPS/Waypoints/index.tsx b/src/pages/iou/request/step/IOURequestStepDistanceGPS/Waypoints/index.tsx index d3e1bcc54db5..6b9e7840f32b 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceGPS/Waypoints/index.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceGPS/Waypoints/index.tsx @@ -65,11 +65,8 @@ function Waypoints({unit, isInLandscapeMode, action, iouType, transactionID, rep return ( + - - - - - Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.PAYMENT_CARD_CURRENCY_SELECTOR.path))} /> diff --git a/src/pages/workspace/distanceRates/PolicyDistanceRateDetailsPage.tsx b/src/pages/workspace/distanceRates/PolicyDistanceRateDetailsPage.tsx index 63496df44757..cc9c435cd52f 100644 --- a/src/pages/workspace/distanceRates/PolicyDistanceRateDetailsPage.tsx +++ b/src/pages/workspace/distanceRates/PolicyDistanceRateDetailsPage.tsx @@ -1,7 +1,6 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton'; import MenuItemAction from '@components/MenuItem/presets/MenuItemAction'; import MenuItemField from '@components/MenuItem/presets/MenuItemField'; -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import {ModalActions} from '@components/Modal/Global/ModalContext'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import ScreenWrapper from '@components/ScreenWrapper'; @@ -218,13 +217,10 @@ function PolicyDistanceRateDetailsPage({route}: PolicyDistanceRateDetailsPagePro errorRowStyles={styles.mh5} onClose={() => clearErrorFields('name')} > - clearErrorFields('rate')} > - clearErrorFields('startDate')} > - clearErrorFields('endDate')} > - {isDistanceTrackTaxEnabled && isPolicyTrackTaxEnabled && ( @@ -295,13 +282,10 @@ function PolicyDistanceRateDetailsPage({route}: PolicyDistanceRateDetailsPagePro errorRowStyles={styles.mh5} onClose={() => clearErrorFields('taxClaimablePercentage')} > - )} diff --git a/src/pages/workspace/workflows/WorkspaceAutoReportingFrequencyPage.tsx b/src/pages/workspace/workflows/WorkspaceAutoReportingFrequencyPage.tsx index 476a69d9d817..f0dab9d93d69 100644 --- a/src/pages/workspace/workflows/WorkspaceAutoReportingFrequencyPage.tsx +++ b/src/pages/workspace/workflows/WorkspaceAutoReportingFrequencyPage.tsx @@ -1,7 +1,7 @@ import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import type {LocaleContextProps} from '@components/LocaleContextProvider'; -import MenuItem from '@components/MenuItem'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import ScreenWrapper from '@components/ScreenWrapper'; import SelectionList from '@components/SelectionList'; @@ -118,14 +118,10 @@ function WorkspaceAutoReportingFrequencyPage({policy, route}: WorkspaceAutoRepor onClose={() => clearPolicyErrorField(policy?.id, CONST.POLICY.COLLECTION_KEYS.AUTOREPORTING_OFFSET)} errorRowStyles={[styles.ml7]} > - Navigation.navigate(ROUTES.WORKSPACE_WORKFLOWS_AUTOREPORTING_MONTHLY_OFFSET.getRoute(policy?.id))} - shouldShowRightIcon /> ); From 0e527ba0116009ffacf5080b3d3a3d78c3afe7dd Mon Sep 17 00:00:00 2001 From: war-in Date: Fri, 25 Sep 2026 11:31:10 +0200 Subject: [PATCH 07/15] refactor: migrate MenuItems with `shouldIconUseAutoWidthStyle` --- .../FlagForReviewRulePageBase.tsx | 85 +++++++++++++------ .../MerchantTypeRulePageBase.tsx | 44 +++++++--- .../RequireFieldsRulePageBase.tsx | 49 ++++++++--- 3 files changed, 125 insertions(+), 53 deletions(-) diff --git a/src/pages/workspace/rules/FlagForReviewRules/FlagForReviewRulePageBase.tsx b/src/pages/workspace/rules/FlagForReviewRules/FlagForReviewRulePageBase.tsx index 221a229e5010..a514d5aa6997 100644 --- a/src/pages/workspace/rules/FlagForReviewRules/FlagForReviewRulePageBase.tsx +++ b/src/pages/workspace/rules/FlagForReviewRules/FlagForReviewRulePageBase.tsx @@ -1,5 +1,8 @@ import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton'; +import FormHelpMessage from '@components/FormHelpMessage'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import MenuItem from '@components/MenuItem'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; @@ -28,7 +31,7 @@ import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import useRuleDeleteHeaderProps from '@pages/workspace/rules/useRuleDeleteHeaderProps'; -import variables from '@styles/variables'; +import {callFunctionIfActionIsAllowed} from '@userActions/Session'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -214,6 +217,9 @@ function FlagForReviewRulePageBase({ /> ) : null; + const categoryError = canEditCategory && shouldShowError && !form?.[INPUT_IDS.CATEGORY] ? translate('common.error.fieldRequired') : ''; + const amountError = canWriteRules && shouldShowError ? getFlagForReviewRuleAmountError(form?.[INPUT_IDS.MAX_EXPENSE_AMOUNT], translate) : ''; + return ( {translate('workspace.rules.flagForReviewRule.subtitle')} {translate('workspace.rules.merchantRules.ifAnyExpenseMatches')} - Navigation.navigate(getFlagForReviewRuleCategoryRoute(policyID, categoryName)) : undefined} - shouldShowRightIcon={canEditCategory} - interactive={canEditCategory} - icon={icons.Folder} - iconWidth={variables.iconSizeNormal} - iconHeight={variables.iconSizeNormal} - shouldIconUseAutoWidthStyle + Navigation.navigate(getFlagForReviewRuleCategoryRoute(policyID, categoryName))) : undefined} sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.RULES.FLAG_FOR_REVIEW_RULE_CATEGORY} - /> - + + + + + + {!!categoryDisplayName && {categoryDisplayName}} + + {canEditCategory && ( + + + + )} + + {!!categoryError && ( + + )} + + + ? callFunctionIfActionIsAllowed(() => Navigation.navigate( isCategoryScopedFlow ? createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_CATEGORY_RULES_FLAG_FOR_REVIEW_AMOUNT.path) : getFlagForReviewRuleAmountRoute(policyID, categoryName, isCategoryLocked), - ) + ), + ) : undefined } - shouldShowRightIcon={canWriteRules} - interactive={canWriteRules} - icon={icons.CoinsButton} - iconWidth={variables.iconSizeNormal} - iconHeight={variables.iconSizeNormal} - shouldIconUseAutoWidthStyle sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.RULES.FLAG_FOR_REVIEW_RULE_AMOUNT} - /> + > + + + + + + {!!maxAmountMenuTitle && {translate('workspace.rules.spendRules.maxAmountAbove', {amount: maxAmountMenuTitle})}} + + {canWriteRules && ( + + + + )} + + {!!amountError && ( + + )} + {translate('workspace.rules.flagForReviewRule.thenDoTheFollowing')} ) : null; + const categoryError = canWriteRules && shouldShowError && !form?.[INPUT_IDS.CATEGORY] ? translate('common.error.fieldRequired') : ''; + return ( {translate('workspace.rules.merchantRules.thenApplyFollowingDefaults')} - Navigation.navigate(ROUTES.RULES_MERCHANT_TYPE_CATEGORY.getRoute(policyID, groupID)) : undefined} - shouldShowRightIcon={canWriteRules} - interactive={canWriteRules} - icon={icons.Folder} - iconWidth={variables.iconSizeNormal} - iconHeight={variables.iconSizeNormal} - shouldIconUseAutoWidthStyle + Navigation.navigate(ROUTES.RULES_MERCHANT_TYPE_CATEGORY.getRoute(policyID, groupID))) : undefined} sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.RULES.MERCHANT_TYPE_RULE_CATEGORY} - /> + > + + + + + + {!!categoryDisplayName && {categoryDisplayName}} + + {canWriteRules && ( + + + + )} + + {!!categoryError && ( + + )} + {footer} diff --git a/src/pages/workspace/rules/RequireFieldsRules/RequireFieldsRulePageBase.tsx b/src/pages/workspace/rules/RequireFieldsRules/RequireFieldsRulePageBase.tsx index 9a84bd296447..6749bb2d6ec1 100644 --- a/src/pages/workspace/rules/RequireFieldsRules/RequireFieldsRulePageBase.tsx +++ b/src/pages/workspace/rules/RequireFieldsRules/RequireFieldsRulePageBase.tsx @@ -1,6 +1,8 @@ import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton'; +import FormHelpMessage from '@components/FormHelpMessage'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import MenuItem from '@components/MenuItem'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import FieldRequirementSettingRow from '@components/RequireFieldsRules/FieldRequirementSettingRow'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; @@ -43,7 +45,7 @@ import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import useRuleDeleteHeaderProps from '@pages/workspace/rules/useRuleDeleteHeaderProps'; -import variables from '@styles/variables'; +import {callFunctionIfActionIsAllowed} from '@userActions/Session'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -436,6 +438,8 @@ function RequireFieldsRulePageBase({policyID, categoryName, initialCategoryName, /> ) : null; + const categoryError = canEditCategory && shouldShowError && !form?.[INPUT_IDS.CATEGORY] ? translate('common.error.fieldRequired') : ''; + return ( {translate('workspace.rules.requireFieldsRule.subtitle')} {translate('workspace.rules.merchantRules.ifAnyExpenseMatches')} - Navigation.navigate(getRequireFieldsRuleCategoryRoute(policyID, isEditing ? categoryName : undefined)) : undefined} - shouldShowRightIcon={canEditCategory} - interactive={canEditCategory} - icon={icons.Folder} - iconWidth={variables.iconSizeNormal} - iconHeight={variables.iconSizeNormal} - shouldIconUseAutoWidthStyle + Navigation.navigate(getRequireFieldsRuleCategoryRoute(policyID, isEditing ? categoryName : undefined))) + : undefined + } sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.RULES.REQUIRE_FIELDS_RULE_CATEGORY} - /> + > + + + + + + {!!categoryDisplayName && {categoryDisplayName}} + + {canEditCategory && ( + + + + )} + + {!!categoryError && ( + + )} + {translate('workspace.rules.requireFieldsRule.doTheFollowing')} From e242924c1a7d1483fe9f27f5a51b0000b4f36d16 Mon Sep 17 00:00:00 2001 From: war-in Date: Wed, 30 Sep 2026 16:38:35 +0200 Subject: [PATCH 08/15] chore: post-merge fixes --- .../sections/AttendeeField.tsx | 32 ++----------------- 1 file changed, 3 insertions(+), 29 deletions(-) diff --git a/src/components/MoneyRequestConfirmationList/sections/AttendeeField.tsx b/src/components/MoneyRequestConfirmationList/sections/AttendeeField.tsx index 9983ad17d84e..fae72eafc3aa 100644 --- a/src/components/MoneyRequestConfirmationList/sections/AttendeeField.tsx +++ b/src/components/MoneyRequestConfirmationList/sections/AttendeeField.tsx @@ -99,38 +99,12 @@ function AttendeeField({formattedAmountPerAttendee, isReadOnly, transactionID, a return ( { - if (!transactionID) { - return; - } - - Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.MONEY_REQUEST_ATTENDEE.getRoute(action, iouType, transactionID, reportID))); - }) - } - accessibilityLabel={`${translate('iou.attendees')}, ${Array.isArray(iouAttendees) ? getAttendeesListDisplayString(iouAttendees) : ''}`} + onPress={isReadOnly ? undefined : callFunctionIfActionIsAllowed(openAttendeePage)} + accessibilityLabel={attendeesAccessibilityLabel} sentryLabel={CONST.SENTRY_LABEL.REQUEST_CONFIRMATION_LIST.ATTENDEES_FIELD} > - 1 && formattedAmountPerAttendee ? `· ${formattedAmountPerAttendee} ${translate('common.perPerson')}` : '' - }`} - > - {Array.isArray(iouAttendees) ? ( - ({ - avatar: a?.avatarUrl, - displayName: a?.displayName ?? a?.email ?? '', - accountID: a?.accountID, - email: a?.email, - }))} - maxVisible={isReadOnly ? iouAttendees.length : undefined} - /> - ) : undefined} - + {attendeePills} {(shouldDisplayAttendeesError || !isReadOnly) && ( {shouldDisplayAttendeesError && } From f96ed561509b8bd180de6da37f7114dc4ec94435 Mon Sep 17 00:00:00 2001 From: war-in Date: Wed, 30 Sep 2026 16:42:34 +0200 Subject: [PATCH 09/15] chore: post-merge fixes --- .../isDeployChecklistLocked/index.js | 151 ++++++++++++++---- .../javascript/proposalPoliceComment/index.js | 151 ++++++++++++++---- 2 files changed, 246 insertions(+), 56 deletions(-) diff --git a/.github/actions/javascript/isDeployChecklistLocked/index.js b/.github/actions/javascript/isDeployChecklistLocked/index.js index b1e68e9965bf..ebdf2bf6961c 100644 --- a/.github/actions/javascript/isDeployChecklistLocked/index.js +++ b/.github/actions/javascript/isDeployChecklistLocked/index.js @@ -23112,6 +23112,7 @@ var require_ExpensiMark = __commonJS({ var Constants = __importStar(require_CONST()); var UrlPatterns = __importStar(require_Url()); var Logger_1 = __importDefault(require_Logger()); + var tlds_1 = __importDefault(require_tlds()); var Utils = __importStar(require_utils()); var EXTRAS_DEFAULT = {}; var ASCII_DIGIT_START = "0".charCodeAt(0); @@ -23124,6 +23125,9 @@ var require_ExpensiMark = __commonJS({ var NON_BREAKING_SPACE_CODE = 160; var URL_PROTOCOLS = ["https://", "http://", "ftps://", "ftp://"]; var URL_CANDIDATE_PREFIX_CHARACTERS = "@_*~"; + var URL_TLD_LIST = tlds_1.default.toLowerCase().split("|"); + var URL_TLDS = new Set(URL_TLD_LIST); + var MAX_URL_TLD_LENGTH = Math.max(...URL_TLD_LIST.map((tld) => tld.length)); var PROTECTED_TAG_NAMES = /* @__PURE__ */ new Set(["a", "code", "pre", "video"]); var MARKDOWN_LINK_REGEX = new RegExp(`\\[((?:[^\\[\\]\\r\\n]*(?:\\[[^\\[\\]\\r\\n]*][^\\[\\]\\r\\n]*)*))]\\(${UrlPatterns.MARKDOWN_URL_REGEX}\\)(?![^<]*(<\\/pre>|<\\/code>))`, "gi"); var MARKDOWN_IMAGE_REGEX = new RegExp(`\\!(?:\\[([^\\][]*(?:\\[[^\\][]*][^\\][]*)*)])?\\(${UrlPatterns.MARKDOWN_URL_REGEX}\\)(?![^<]*(<\\/pre>|<\\/code>))`, "gi"); @@ -23169,6 +23173,9 @@ var require_ExpensiMark = __commonJS({ } return text.replace(regexp, replacement); } + function canUseCandidateScanning(text, shouldEscapeText) { + return shouldEscapeText || !text.includes("<") && !text.includes(">"); + } function isAsciiAlphaNumeric(character) { if (!character) { return false; @@ -23240,15 +23247,59 @@ var require_ExpensiMark = __commonJS({ } return URL_PROTOCOLS.find((protocol) => text.slice(position, position + protocol.length).toLowerCase() === protocol); } - function findHostnameEnd(text, hostnameStart, dotPosition) { - let hostnameEnd = dotPosition + 1; - while (hostnameEnd < text.length && (isAsciiAlphaNumeric(text[hostnameEnd]) || text[hostnameEnd] === "-")) { - hostnameEnd++; + function isValidHostnameLabel(text, start, end) { + if (start >= end || !isAsciiAlphaNumeric(text[start]) || !isAsciiAlphaNumeric(text[end - 1])) { + return false; } - if (hostnameStart === dotPosition || hostnameEnd === dotPosition + 1) { - return void 0; + for (let index = start + 1; index < end - 1; index++) { + if (!isAsciiAlphaNumeric(text[index]) && text[index] !== "-") { + return false; + } + } + return true; + } + function findHostnameStart(text, dotPosition) { + let hostnameStart = dotPosition; + let labelEnd = dotPosition; + while (labelEnd > 0) { + let rawLabelStart = labelEnd - 1; + while (rawLabelStart >= 0 && text[rawLabelStart] !== "." && isHostnameCharacter(text[rawLabelStart])) { + rawLabelStart--; + } + rawLabelStart++; + let labelStart = rawLabelStart; + while (labelStart < labelEnd && text[labelStart] === "-") { + labelStart++; + } + if (!isValidHostnameLabel(text, labelStart, labelEnd)) { + break; + } + hostnameStart = labelStart; + if (labelStart !== rawLabelStart) { + break; + } + const separatorPosition = labelStart - 1; + if (separatorPosition < 0 || text[separatorPosition] !== ".") { + break; + } + labelEnd = separatorPosition; } - return hostnameEnd; + return hostnameStart === dotPosition ? void 0 : hostnameStart; + } + function findKnownTldEnd(text, dotPosition) { + const maximumEnd = Math.min(text.length, dotPosition + 1 + MAX_URL_TLD_LENGTH); + for (let end = dotPosition + 2; end <= maximumEnd; end++) { + const currentCharacter = text[end - 1]; + if (!isAsciiAlphaNumeric(currentCharacter) && currentCharacter !== "-") { + break; + } + const nextCharacter = text[end]; + const hasValidBoundary = !nextCharacter || nextCharacter === ":" || nextCharacter === "_" || !isWordCharacter(nextCharacter); + if (hasValidBoundary && URL_TLDS.has(text.slice(dotPosition + 1, end).toLowerCase())) { + return end; + } + } + return void 0; } function extendUrlCandidateBoundaries(text, start, end) { let candidateStart = start; @@ -23261,24 +23312,65 @@ var require_ExpensiMark = __commonJS({ } return { start: candidateStart, end: candidateEnd }; } + function startsWithIgnoreCase(text, expected, position) { + return text.slice(position, position + expected.length).toLowerCase() === expected; + } + function filterUrlCandidatesBlockedByFollowingHtml(text, candidates) { + var _a; + if (candidates.length === 0 || !text.includes("<") && !text.includes(">")) { + return candidates; + } + const validCandidates = []; + let candidateIndex = candidates.length - 1; + let nextLessThan = text.length; + let nextGreaterThan = text.length; + let nextOpeningAnchor = text.length; + let nextClosingAnchor = text.length; + for (let index = text.length; index >= 0 && candidateIndex >= 0; index--) { + if (text[index] === "<") { + nextLessThan = index; + if (((_a = text[index + 1]) === null || _a === void 0 ? void 0 : _a.toLowerCase()) === "a") { + nextOpeningAnchor = index; + } else if (startsWithIgnoreCase(text, "", index)) { + nextClosingAnchor = index; + } + } else if (text[index] === ">") { + nextGreaterThan = index; + } + while (candidateIndex >= 0 && candidates[candidateIndex].end === index) { + const firstHtmlBoundaryIsClosingTag = nextLessThan < nextGreaterThan && text.startsWith("", nextLessThan); + const firstTagIsProtectedClosingTag = startsWithIgnoreCase(text, "", nextLessThan) || startsWithIgnoreCase(text, "", nextLessThan); + const isBlockedByFollowingHtml = ( + // Mirrors `(?![^<]*>)`: reject when `>` appears before the next `<`. + nextGreaterThan < nextLessThan || // Mirrors `[^<>]*<\/(?!h1>)`: reject a later closing tag other than ``. + firstHtmlBoundaryIsClosingTag || // Mirrors `((?:(?!`: reject `` unless another `` appears first. + nextClosingAnchor < nextOpeningAnchor || // Mirrors `[^<]*(<\/pre>|<\/code>)`: reject a later protected closing tag. + firstTagIsProtectedClosingTag + ); + if (!isBlockedByFollowingHtml) { + validCandidates.push(candidates[candidateIndex]); + } + candidateIndex--; + } + } + return validCandidates.reverse(); + } function findUrlCandidates(text) { const candidates = []; const protectedTags = []; let index = 0; - let hostnameRunStart = 0; while (index < text.length) { if (text[index] === "<") { const nextIndex = updateProtectedTagStack(text, index, protectedTags); if (nextIndex === void 0) { - break; + index++; + continue; } index = nextIndex; - hostnameRunStart = index; continue; } if (protectedTags.length > 0) { index++; - hostnameRunStart = index; continue; } const matchedProtocol = getProtocolAt(text, index); @@ -23286,29 +23378,23 @@ var require_ExpensiMark = __commonJS({ const candidate2 = extendUrlCandidateBoundaries(text, index, index + matchedProtocol.length); candidates.push(candidate2); index = candidate2.end; - hostnameRunStart = candidate2.end; - continue; - } - if (!isHostnameCharacter(text[index])) { - hostnameRunStart = index + 1; - index++; continue; } if (text[index] !== ".") { index++; continue; } - const hostnameEnd = findHostnameEnd(text, hostnameRunStart, index); - if (hostnameEnd === void 0) { + const tldEnd = findKnownTldEnd(text, index); + const hostnameStart = tldEnd === void 0 ? void 0 : findHostnameStart(text, index); + if (tldEnd === void 0 || hostnameStart === void 0) { index++; continue; } - const candidate = extendUrlCandidateBoundaries(text, hostnameRunStart, hostnameEnd); + const candidate = extendUrlCandidateBoundaries(text, hostnameStart, tldEnd); candidates.push(candidate); index = candidate.end; - hostnameRunStart = candidate.end; } - return candidates; + return filterUrlCandidatesBlockedByFollowingHtml(text, candidates); } function replaceMarkdownCandidates(text, regexp, replacement, marker, canOpen) { if (!text.includes(marker)) { @@ -23381,6 +23467,14 @@ var require_ExpensiMark = __commonJS({ output.push(text.slice(outputStart)); return output.join(""); } + function processMarkdownRule(regex2, marker, canOpen) { + return (textToProcess, replacement, _shouldKeepRawInput, shouldEscapeText) => { + if (canUseCandidateScanning(textToProcess, shouldEscapeText)) { + return replaceMarkdownCandidates(textToProcess, regex2, replacement, marker, canOpen); + } + return replaceTextWithExtras(textToProcess, regex2, EXTRAS_DEFAULT, replacement); + }; + } function replaceBlockElementWithNewLine(htmlString) { let splitText = htmlString.replaceAll(/
> (|<\/div>||\n<\/comment>|<\/comment>|

|<\/h1>|

|<\/h2>|

|<\/h3>|

|<\/h4>|

|<\/h5>|
|<\/h6>|

|<\/p>|

  • |<\/li>)/gi, "
    > ").split(/|<\/div>||\n<\/comment>|<\/comment>|

    |<\/h1>|

    |<\/h2>|

    |<\/h3>|

    |<\/h4>|

    |<\/h5>|
    |<\/h6>|

    |<\/p>|

  • |<\/li>|
    |<\/blockquote>/); const stripHTML = (text) => str_1.default.stripHTML(text); @@ -23799,9 +23893,9 @@ var require_ExpensiMark = __commonJS({ */ { name: "autolink", - process: (textToProcess, replacement) => { + process: (textToProcess, replacement, _shouldKeepRawInput, shouldEscapeText) => { const regex2 = new RegExp(`(?![^<]*>|[^<>]*<\\/(?!h1>))([_*~]*?)${UrlPatterns.MARKDOWN_URL_REGEX}\\1(?!((?:(?!|[^<]*(<\\/pre>|<\\/code>))`, "gi"); - return this.modifyTextForUrlLinks(regex2, textToProcess, replacement, true); + return this.modifyTextForUrlLinks(regex2, textToProcess, replacement, canUseCandidateScanning(textToProcess, shouldEscapeText)); }, replacement: (_extras, _match, g1, g2) => { const href = str_1.default.sanitizeURL(g2); @@ -23870,7 +23964,8 @@ ${"
    ".repeat(i)}`, "\n"); name: "autoEmail", regex: new RegExp(`([^\\w'#%+-]|^)${Constants.CONST.REG_EXP.MARKDOWN_EMAIL}(?!((?:(?!|[^<>]*<\\/(?!em|h1|blockquote))`, "gim"), replacement: '$1$2', - rawInputReplacement: '$1$2' + rawInputReplacement: '$1$2', + shouldSkipProcessing: (textToCheck) => !textToCheck.includes("@") }, /** * This regex matches a short user mention in a string. @@ -23906,7 +24001,7 @@ ${"
    ".repeat(i)}`, "\n"); // \B will match everything that \b doesn't, so it works // for * and ~: https://www.rexegg.com/regex-boundaries.html#notb name: "bold", - process: (textToProcess, replacement) => replaceMarkdownCandidates(textToProcess, BOLD_MARKDOWN_REGEX, replacement, "*", canOpenBoldMarkdown), + process: processMarkdownRule(BOLD_MARKDOWN_REGEX, "*", canOpenBoldMarkdown), replacement: (_extras, match, g1, g2) => { if (g1.includes("_")) { return `${g1}${g2}`; @@ -23916,7 +24011,7 @@ ${"
    ".repeat(i)}`, "\n"); }, { name: "strikethrough", - process: (textToProcess, replacement) => replaceMarkdownCandidates(textToProcess, STRIKETHROUGH_MARKDOWN_REGEX, replacement, "~", canOpenStrikethroughMarkdown), + process: processMarkdownRule(STRIKETHROUGH_MARKDOWN_REGEX, "~", canOpenStrikethroughMarkdown), replacement: (_extras, match, g1) => g1.includes("") || containsNonPairTag(g1) ? match : `${g1}` }, { @@ -24285,7 +24380,7 @@ ${g2} } const replacement = shouldKeepRawInput && rule.rawInputReplacement ? rule.rawInputReplacement : rule.replacement; if ("process" in rule) { - replacedText = rule.process(replacedText, replacement, shouldKeepRawInput); + replacedText = rule.process(replacedText, replacement, shouldKeepRawInput, shouldEscapeText); } else { replacedText = replaceTextWithExtras(replacedText, rule.regex, extras, replacement); } diff --git a/.github/actions/javascript/proposalPoliceComment/index.js b/.github/actions/javascript/proposalPoliceComment/index.js index 6c66aba05ee9..126b34462df4 100644 --- a/.github/actions/javascript/proposalPoliceComment/index.js +++ b/.github/actions/javascript/proposalPoliceComment/index.js @@ -43926,6 +43926,7 @@ var require_ExpensiMark = __commonJS({ var Constants = __importStar(require_CONST()); var UrlPatterns = __importStar(require_Url()); var Logger_1 = __importDefault(require_Logger()); + var tlds_1 = __importDefault(require_tlds()); var Utils = __importStar(require_utils2()); var EXTRAS_DEFAULT = {}; var ASCII_DIGIT_START = "0".charCodeAt(0); @@ -43938,6 +43939,9 @@ var require_ExpensiMark = __commonJS({ var NON_BREAKING_SPACE_CODE = 160; var URL_PROTOCOLS = ["https://", "http://", "ftps://", "ftp://"]; var URL_CANDIDATE_PREFIX_CHARACTERS = "@_*~"; + var URL_TLD_LIST = tlds_1.default.toLowerCase().split("|"); + var URL_TLDS = new Set(URL_TLD_LIST); + var MAX_URL_TLD_LENGTH = Math.max(...URL_TLD_LIST.map((tld) => tld.length)); var PROTECTED_TAG_NAMES = /* @__PURE__ */ new Set(["a", "code", "pre", "video"]); var MARKDOWN_LINK_REGEX = new RegExp(`\\[((?:[^\\[\\]\\r\\n]*(?:\\[[^\\[\\]\\r\\n]*][^\\[\\]\\r\\n]*)*))]\\(${UrlPatterns.MARKDOWN_URL_REGEX}\\)(?![^<]*(<\\/pre>|<\\/code>))`, "gi"); var MARKDOWN_IMAGE_REGEX = new RegExp(`\\!(?:\\[([^\\][]*(?:\\[[^\\][]*][^\\][]*)*)])?\\(${UrlPatterns.MARKDOWN_URL_REGEX}\\)(?![^<]*(<\\/pre>|<\\/code>))`, "gi"); @@ -43983,6 +43987,9 @@ var require_ExpensiMark = __commonJS({ } return text.replace(regexp, replacement); } + function canUseCandidateScanning(text, shouldEscapeText) { + return shouldEscapeText || !text.includes("<") && !text.includes(">"); + } function isAsciiAlphaNumeric(character) { if (!character) { return false; @@ -44054,15 +44061,59 @@ var require_ExpensiMark = __commonJS({ } return URL_PROTOCOLS.find((protocol) => text.slice(position, position + protocol.length).toLowerCase() === protocol); } - function findHostnameEnd(text, hostnameStart, dotPosition) { - let hostnameEnd = dotPosition + 1; - while (hostnameEnd < text.length && (isAsciiAlphaNumeric(text[hostnameEnd]) || text[hostnameEnd] === "-")) { - hostnameEnd++; + function isValidHostnameLabel(text, start, end) { + if (start >= end || !isAsciiAlphaNumeric(text[start]) || !isAsciiAlphaNumeric(text[end - 1])) { + return false; } - if (hostnameStart === dotPosition || hostnameEnd === dotPosition + 1) { - return void 0; + for (let index = start + 1; index < end - 1; index++) { + if (!isAsciiAlphaNumeric(text[index]) && text[index] !== "-") { + return false; + } + } + return true; + } + function findHostnameStart(text, dotPosition) { + let hostnameStart = dotPosition; + let labelEnd = dotPosition; + while (labelEnd > 0) { + let rawLabelStart = labelEnd - 1; + while (rawLabelStart >= 0 && text[rawLabelStart] !== "." && isHostnameCharacter(text[rawLabelStart])) { + rawLabelStart--; + } + rawLabelStart++; + let labelStart = rawLabelStart; + while (labelStart < labelEnd && text[labelStart] === "-") { + labelStart++; + } + if (!isValidHostnameLabel(text, labelStart, labelEnd)) { + break; + } + hostnameStart = labelStart; + if (labelStart !== rawLabelStart) { + break; + } + const separatorPosition = labelStart - 1; + if (separatorPosition < 0 || text[separatorPosition] !== ".") { + break; + } + labelEnd = separatorPosition; } - return hostnameEnd; + return hostnameStart === dotPosition ? void 0 : hostnameStart; + } + function findKnownTldEnd(text, dotPosition) { + const maximumEnd = Math.min(text.length, dotPosition + 1 + MAX_URL_TLD_LENGTH); + for (let end = dotPosition + 2; end <= maximumEnd; end++) { + const currentCharacter = text[end - 1]; + if (!isAsciiAlphaNumeric(currentCharacter) && currentCharacter !== "-") { + break; + } + const nextCharacter = text[end]; + const hasValidBoundary = !nextCharacter || nextCharacter === ":" || nextCharacter === "_" || !isWordCharacter(nextCharacter); + if (hasValidBoundary && URL_TLDS.has(text.slice(dotPosition + 1, end).toLowerCase())) { + return end; + } + } + return void 0; } function extendUrlCandidateBoundaries(text, start, end) { let candidateStart = start; @@ -44075,24 +44126,65 @@ var require_ExpensiMark = __commonJS({ } return { start: candidateStart, end: candidateEnd }; } + function startsWithIgnoreCase(text, expected, position) { + return text.slice(position, position + expected.length).toLowerCase() === expected; + } + function filterUrlCandidatesBlockedByFollowingHtml(text, candidates) { + var _a3; + if (candidates.length === 0 || !text.includes("<") && !text.includes(">")) { + return candidates; + } + const validCandidates = []; + let candidateIndex = candidates.length - 1; + let nextLessThan = text.length; + let nextGreaterThan = text.length; + let nextOpeningAnchor = text.length; + let nextClosingAnchor = text.length; + for (let index = text.length; index >= 0 && candidateIndex >= 0; index--) { + if (text[index] === "<") { + nextLessThan = index; + if (((_a3 = text[index + 1]) === null || _a3 === void 0 ? void 0 : _a3.toLowerCase()) === "a") { + nextOpeningAnchor = index; + } else if (startsWithIgnoreCase(text, "", index)) { + nextClosingAnchor = index; + } + } else if (text[index] === ">") { + nextGreaterThan = index; + } + while (candidateIndex >= 0 && candidates[candidateIndex].end === index) { + const firstHtmlBoundaryIsClosingTag = nextLessThan < nextGreaterThan && text.startsWith("", nextLessThan); + const firstTagIsProtectedClosingTag = startsWithIgnoreCase(text, "", nextLessThan) || startsWithIgnoreCase(text, "", nextLessThan); + const isBlockedByFollowingHtml = ( + // Mirrors `(?![^<]*>)`: reject when `>` appears before the next `<`. + nextGreaterThan < nextLessThan || // Mirrors `[^<>]*<\/(?!h1>)`: reject a later closing tag other than `
  • `. + firstHtmlBoundaryIsClosingTag || // Mirrors `((?:(?!`: reject `` unless another `` appears first. + nextClosingAnchor < nextOpeningAnchor || // Mirrors `[^<]*(<\/pre>|<\/code>)`: reject a later protected closing tag. + firstTagIsProtectedClosingTag + ); + if (!isBlockedByFollowingHtml) { + validCandidates.push(candidates[candidateIndex]); + } + candidateIndex--; + } + } + return validCandidates.reverse(); + } function findUrlCandidates(text) { const candidates = []; const protectedTags = []; let index = 0; - let hostnameRunStart = 0; while (index < text.length) { if (text[index] === "<") { const nextIndex = updateProtectedTagStack(text, index, protectedTags); if (nextIndex === void 0) { - break; + index++; + continue; } index = nextIndex; - hostnameRunStart = index; continue; } if (protectedTags.length > 0) { index++; - hostnameRunStart = index; continue; } const matchedProtocol = getProtocolAt(text, index); @@ -44100,29 +44192,23 @@ var require_ExpensiMark = __commonJS({ const candidate2 = extendUrlCandidateBoundaries(text, index, index + matchedProtocol.length); candidates.push(candidate2); index = candidate2.end; - hostnameRunStart = candidate2.end; - continue; - } - if (!isHostnameCharacter(text[index])) { - hostnameRunStart = index + 1; - index++; continue; } if (text[index] !== ".") { index++; continue; } - const hostnameEnd = findHostnameEnd(text, hostnameRunStart, index); - if (hostnameEnd === void 0) { + const tldEnd = findKnownTldEnd(text, index); + const hostnameStart = tldEnd === void 0 ? void 0 : findHostnameStart(text, index); + if (tldEnd === void 0 || hostnameStart === void 0) { index++; continue; } - const candidate = extendUrlCandidateBoundaries(text, hostnameRunStart, hostnameEnd); + const candidate = extendUrlCandidateBoundaries(text, hostnameStart, tldEnd); candidates.push(candidate); index = candidate.end; - hostnameRunStart = candidate.end; } - return candidates; + return filterUrlCandidatesBlockedByFollowingHtml(text, candidates); } function replaceMarkdownCandidates(text, regexp, replacement, marker, canOpen) { if (!text.includes(marker)) { @@ -44195,6 +44281,14 @@ var require_ExpensiMark = __commonJS({ output.push(text.slice(outputStart)); return output.join(""); } + function processMarkdownRule(regex2, marker, canOpen) { + return (textToProcess, replacement, _shouldKeepRawInput, shouldEscapeText) => { + if (canUseCandidateScanning(textToProcess, shouldEscapeText)) { + return replaceMarkdownCandidates(textToProcess, regex2, replacement, marker, canOpen); + } + return replaceTextWithExtras(textToProcess, regex2, EXTRAS_DEFAULT, replacement); + }; + } function replaceBlockElementWithNewLine(htmlString) { let splitText = htmlString.replaceAll(/
    > (|<\/div>||\n<\/comment>|<\/comment>|

    |<\/h1>|

    |<\/h2>|

    |<\/h3>|

    |<\/h4>|

    |<\/h5>|
    |<\/h6>|

    |<\/p>|

  • |<\/li>)/gi, "
    > ").split(/|<\/div>||\n<\/comment>|<\/comment>|

    |<\/h1>|

    |<\/h2>|

    |<\/h3>|

    |<\/h4>|

    |<\/h5>|
    |<\/h6>|

    |<\/p>|

  • |<\/li>|
    |<\/blockquote>/); const stripHTML = (text) => str_1.default.stripHTML(text); @@ -44613,9 +44707,9 @@ var require_ExpensiMark = __commonJS({ */ { name: "autolink", - process: (textToProcess, replacement) => { + process: (textToProcess, replacement, _shouldKeepRawInput, shouldEscapeText) => { const regex2 = new RegExp(`(?![^<]*>|[^<>]*<\\/(?!h1>))([_*~]*?)${UrlPatterns.MARKDOWN_URL_REGEX}\\1(?!((?:(?!|[^<]*(<\\/pre>|<\\/code>))`, "gi"); - return this.modifyTextForUrlLinks(regex2, textToProcess, replacement, true); + return this.modifyTextForUrlLinks(regex2, textToProcess, replacement, canUseCandidateScanning(textToProcess, shouldEscapeText)); }, replacement: (_extras, _match, g1, g2) => { const href = str_1.default.sanitizeURL(g2); @@ -44684,7 +44778,8 @@ ${"
    ".repeat(i)}`, "\n"); name: "autoEmail", regex: new RegExp(`([^\\w'#%+-]|^)${Constants.CONST.REG_EXP.MARKDOWN_EMAIL}(?!((?:(?!|[^<>]*<\\/(?!em|h1|blockquote))`, "gim"), replacement: '$1$2', - rawInputReplacement: '$1$2' + rawInputReplacement: '$1$2', + shouldSkipProcessing: (textToCheck) => !textToCheck.includes("@") }, /** * This regex matches a short user mention in a string. @@ -44720,7 +44815,7 @@ ${"
    ".repeat(i)}`, "\n"); // \B will match everything that \b doesn't, so it works // for * and ~: https://www.rexegg.com/regex-boundaries.html#notb name: "bold", - process: (textToProcess, replacement) => replaceMarkdownCandidates(textToProcess, BOLD_MARKDOWN_REGEX, replacement, "*", canOpenBoldMarkdown), + process: processMarkdownRule(BOLD_MARKDOWN_REGEX, "*", canOpenBoldMarkdown), replacement: (_extras, match2, g1, g2) => { if (g1.includes("_")) { return `${g1}${g2}`; @@ -44730,7 +44825,7 @@ ${"
    ".repeat(i)}`, "\n"); }, { name: "strikethrough", - process: (textToProcess, replacement) => replaceMarkdownCandidates(textToProcess, STRIKETHROUGH_MARKDOWN_REGEX, replacement, "~", canOpenStrikethroughMarkdown), + process: processMarkdownRule(STRIKETHROUGH_MARKDOWN_REGEX, "~", canOpenStrikethroughMarkdown), replacement: (_extras, match2, g1) => g1.includes("") || containsNonPairTag(g1) ? match2 : `${g1}` }, { @@ -45099,7 +45194,7 @@ ${g2} } const replacement = shouldKeepRawInput && rule.rawInputReplacement ? rule.rawInputReplacement : rule.replacement; if ("process" in rule) { - replacedText = rule.process(replacedText, replacement, shouldKeepRawInput); + replacedText = rule.process(replacedText, replacement, shouldKeepRawInput, shouldEscapeText); } else { replacedText = replaceTextWithExtras(replacedText, rule.regex, extras, replacement); } From d6144b1263b35d44182d230b20bec08c8e26268f Mon Sep 17 00:00:00 2001 From: war-in Date: Thu, 1 Oct 2026 10:25:01 +0200 Subject: [PATCH 10/15] fix: remove unused imports --- src/pages/Travel/FlightTripDetails.tsx | 1 - src/pages/Travel/TrainTripDetails.tsx | 1 - 2 files changed, 2 deletions(-) diff --git a/src/pages/Travel/FlightTripDetails.tsx b/src/pages/Travel/FlightTripDetails.tsx index 81576d2561a5..925d1b3960e2 100644 --- a/src/pages/Travel/FlightTripDetails.tsx +++ b/src/pages/Travel/FlightTripDetails.tsx @@ -2,7 +2,6 @@ import FormHelpMessage from '@components/FormHelpMessage'; import Icon from '@components/Icon'; import MenuItem from '@components/MenuItem'; import MenuItemField from '@components/MenuItem/presets/MenuItemField'; -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import RenderHTML from '@components/RenderHTML'; import Text from '@components/Text'; import UserPills from '@components/UserPills'; diff --git a/src/pages/Travel/TrainTripDetails.tsx b/src/pages/Travel/TrainTripDetails.tsx index 4939f16d26e2..b085b91fa6b2 100644 --- a/src/pages/Travel/TrainTripDetails.tsx +++ b/src/pages/Travel/TrainTripDetails.tsx @@ -1,7 +1,6 @@ import FormHelpMessage from '@components/FormHelpMessage'; import MenuItem from '@components/MenuItem'; import MenuItemField from '@components/MenuItem/presets/MenuItemField'; -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import Text from '@components/Text'; import UserPills from '@components/UserPills'; From 3be3e9f2a63f2a231c111147424b6028a6f7687b Mon Sep 17 00:00:00 2001 From: war-in Date: Mon, 5 Oct 2026 11:29:51 +0200 Subject: [PATCH 11/15] fix: migrate MenuItemWithTopDescription to MenuItem --- .../ReportDetailsTitleSection.tsx | 39 +++++++++---------- 1 file changed, 19 insertions(+), 20 deletions(-) diff --git a/src/pages/DynamicReportDetailsPage/ReportDetailsTitleSection.tsx b/src/pages/DynamicReportDetailsPage/ReportDetailsTitleSection.tsx index d28c4df3a0e4..5d73ca1ea113 100644 --- a/src/pages/DynamicReportDetailsPage/ReportDetailsTitleSection.tsx +++ b/src/pages/DynamicReportDetailsPage/ReportDetailsTitleSection.tsx @@ -1,3 +1,5 @@ +import MenuItem from '@components/MenuItem'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import ParentNavigationSubtitle from '@components/ParentNavigationSubtitle'; @@ -117,26 +119,23 @@ function ReportDetailsTitleSectionContent({report}: ReportDetailsTitleSectionCon {shouldShowFurtherDetailsContent && ( - - } - description={translate('threads.from')} - descriptionTextStyle={[styles.mutedNormalTextLabel, styles.mb1]} - shouldCheckActionAllowedOnPress={false} - /> + + + + + + + )} ); From 081e30677326922fb193ea31b0b7338706ca51fa Mon Sep 17 00:00:00 2001 From: war-in Date: Tue, 6 Oct 2026 18:36:25 +0200 Subject: [PATCH 12/15] fix: add accessibility label --- .../DynamicReportDetailsPage/ReportDetailsTitleSection.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/DynamicReportDetailsPage/ReportDetailsTitleSection.tsx b/src/pages/DynamicReportDetailsPage/ReportDetailsTitleSection.tsx index 5d73ca1ea113..a636ac60da59 100644 --- a/src/pages/DynamicReportDetailsPage/ReportDetailsTitleSection.tsx +++ b/src/pages/DynamicReportDetailsPage/ReportDetailsTitleSection.tsx @@ -119,7 +119,7 @@ function ReportDetailsTitleSectionContent({report}: ReportDetailsTitleSectionCon {shouldShowFurtherDetailsContent && ( - + Date: Tue, 6 Oct 2026 18:41:57 +0200 Subject: [PATCH 13/15] refactor: use MenuItem.HelpText for attendee --- .../sections/AttendeeField.tsx | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/components/MoneyRequestConfirmationList/sections/AttendeeField.tsx b/src/components/MoneyRequestConfirmationList/sections/AttendeeField.tsx index fae72eafc3aa..ddb169fd0752 100644 --- a/src/components/MoneyRequestConfirmationList/sections/AttendeeField.tsx +++ b/src/components/MoneyRequestConfirmationList/sections/AttendeeField.tsx @@ -1,4 +1,3 @@ -import FormHelpMessage from '@components/FormHelpMessage'; import MenuItem from '@components/MenuItem'; import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import {usePersonalDetails} from '@components/OnyxListItemProvider'; @@ -7,7 +6,6 @@ import UserPills from '@components/UserPills'; import useAttendees from '@hooks/useAttendees'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; -import useThemeStyles from '@hooks/useThemeStyles'; import {enrichAndSortAttendees} from '@libs/AttendeeUtils'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; @@ -44,7 +42,6 @@ type AttendeeFieldProps = { function AttendeeField({formattedAmountPerAttendee, isReadOnly, transactionID, action, iouType, reportID, formError}: AttendeeFieldProps) { const {shouldUseDropdownRows} = useExpenseFormLayout(); - const styles = useThemeStyles(); const {translate, localeCompare} = useLocalize(); const personalDetailsList = usePersonalDetails(); const [loginToAccountIDMap] = useOnyx(ONYXKEYS.DERIVED.LOGIN_TO_ACCOUNT_ID_MAP); @@ -113,11 +110,9 @@ function AttendeeField({formattedAmountPerAttendee, isReadOnly, transactionID, a )} {shouldDisplayAttendeesError && ( - )} From 11ad0094b052ab4f4335b9bb34a527850541fd32 Mon Sep 17 00:00:00 2001 From: war-in Date: Tue, 6 Oct 2026 19:21:59 +0200 Subject: [PATCH 14/15] fix: move `DistanceCounter` back and apply correct padding --- .../step/IOURequestStepDistanceGPS/Waypoints/index.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/pages/iou/request/step/IOURequestStepDistanceGPS/Waypoints/index.tsx b/src/pages/iou/request/step/IOURequestStepDistanceGPS/Waypoints/index.tsx index 6b9e7840f32b..a4ef59b34dc9 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceGPS/Waypoints/index.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceGPS/Waypoints/index.tsx @@ -65,8 +65,10 @@ function Waypoints({unit, isInLandscapeMode, action, iouType, transactionID, rep return ( - - + + + + Date: Thu, 8 Oct 2026 17:57:36 +0200 Subject: [PATCH 15/15] refactor: migrate plain `FormHelpMessage` to MenuItem.HelpText --- .../subSteps/TypeBusiness/BusinessTypePicker/index.tsx | 7 +------ .../FlagForReviewRules/FlagForReviewRulePageBase.tsx | 9 ++------- .../rules/MerchantTypeRules/MerchantTypeRulePageBase.tsx | 5 +---- .../RequireFieldsRules/RequireFieldsRulePageBase.tsx | 5 +---- 4 files changed, 5 insertions(+), 21 deletions(-) diff --git a/src/pages/ReimbursementAccount/USD/BusinessInfo/subSteps/TypeBusiness/BusinessTypePicker/index.tsx b/src/pages/ReimbursementAccount/USD/BusinessInfo/subSteps/TypeBusiness/BusinessTypePicker/index.tsx index fd85cf05bb02..e8c57673a3ba 100644 --- a/src/pages/ReimbursementAccount/USD/BusinessInfo/subSteps/TypeBusiness/BusinessTypePicker/index.tsx +++ b/src/pages/ReimbursementAccount/USD/BusinessInfo/subSteps/TypeBusiness/BusinessTypePicker/index.tsx @@ -1,9 +1,7 @@ -import FormHelpMessage from '@components/FormHelpMessage'; import MenuItem from '@components/MenuItem'; import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import useLocalize from '@hooks/useLocalize'; -import useThemeStyles from '@hooks/useThemeStyles'; import {callFunctionIfActionIsAllowed} from '@userActions/Session'; @@ -35,7 +33,6 @@ type BusinessTypePickerProps = { }; function BusinessTypePicker({errorText = '', value = '', wrapperStyle, onInputChange, label, onBlur}: BusinessTypePickerProps) { - const styles = useThemeStyles(); const {translate} = useLocalize(); const [isPickerVisible, setIsPickerVisible] = useState(false); @@ -73,11 +70,9 @@ function BusinessTypePicker({errorText = '', value = '', wrapperStyle, onInputCh {!!errorText && ( - )} diff --git a/src/pages/workspace/rules/FlagForReviewRules/FlagForReviewRulePageBase.tsx b/src/pages/workspace/rules/FlagForReviewRules/FlagForReviewRulePageBase.tsx index 44deb5d03a7e..631e194ff416 100644 --- a/src/pages/workspace/rules/FlagForReviewRules/FlagForReviewRulePageBase.tsx +++ b/src/pages/workspace/rules/FlagForReviewRules/FlagForReviewRulePageBase.tsx @@ -1,5 +1,4 @@ import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton'; -import FormHelpMessage from '@components/FormHelpMessage'; import Header from '@components/Header'; import HeaderWithBackButtonAndTitle from '@components/Header/composed/HeaderWithBackButtonAndTitle'; import MenuItem from '@components/MenuItem'; @@ -260,11 +259,9 @@ function FlagForReviewRulePageBase({ )} {!!categoryError && ( - )} @@ -296,11 +293,9 @@ function FlagForReviewRulePageBase({ )} {!!amountError && ( - )} diff --git a/src/pages/workspace/rules/MerchantTypeRules/MerchantTypeRulePageBase.tsx b/src/pages/workspace/rules/MerchantTypeRules/MerchantTypeRulePageBase.tsx index d890bf14d60c..75f8a89b94e4 100644 --- a/src/pages/workspace/rules/MerchantTypeRules/MerchantTypeRulePageBase.tsx +++ b/src/pages/workspace/rules/MerchantTypeRules/MerchantTypeRulePageBase.tsx @@ -1,5 +1,4 @@ import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton'; -import FormHelpMessage from '@components/FormHelpMessage'; import HeaderWithBackButtonAndTitle from '@components/Header/composed/HeaderWithBackButtonAndTitle'; import MenuItem from '@components/MenuItem'; import MenuItemField from '@components/MenuItem/presets/MenuItemField'; @@ -197,11 +196,9 @@ function MerchantTypeRulePageBase({policyID, groupID, testID}: MerchantTypeRuleP )} {!!categoryError && ( - )} diff --git a/src/pages/workspace/rules/RequireFieldsRules/RequireFieldsRulePageBase.tsx b/src/pages/workspace/rules/RequireFieldsRules/RequireFieldsRulePageBase.tsx index 81a3ff200f1b..acdf89b3f71d 100644 --- a/src/pages/workspace/rules/RequireFieldsRules/RequireFieldsRulePageBase.tsx +++ b/src/pages/workspace/rules/RequireFieldsRules/RequireFieldsRulePageBase.tsx @@ -1,5 +1,4 @@ import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton'; -import FormHelpMessage from '@components/FormHelpMessage'; import Header from '@components/Header'; import HeaderWithBackButtonAndTitle from '@components/Header/composed/HeaderWithBackButtonAndTitle'; import MenuItem from '@components/MenuItem'; @@ -483,11 +482,9 @@ function RequireFieldsRulePageBase({policyID, categoryName, initialCategoryName, )} {!!categoryError && ( - )}