From 9d8a629672312a40b6d0d09a3b875dad1426664e Mon Sep 17 00:00:00 2001 From: nkdengineer Date: Mon, 26 Jan 2026 16:56:17 +0700 Subject: [PATCH 1/4] fix: The icon of link opening new page/tab is not announced --- src/components/MenuItem.tsx | 19 ++++++++++++++++--- src/languages/de.ts | 1 + src/languages/en.ts | 1 + src/languages/es.ts | 1 + src/languages/fr.ts | 1 + src/languages/it.ts | 1 + src/languages/ja.ts | 1 + src/languages/nl.ts | 1 + src/languages/pl.ts | 1 + src/languages/pt-BR.ts | 1 + src/languages/zh-hans.ts | 1 + 11 files changed, 26 insertions(+), 3 deletions(-) diff --git a/src/components/MenuItem.tsx b/src/components/MenuItem.tsx index 23d5d73eb3d2..00be9ad07831 100644 --- a/src/components/MenuItem.tsx +++ b/src/components/MenuItem.tsx @@ -5,6 +5,7 @@ import type {GestureResponderEvent, Role, StyleProp, TextStyle, ViewStyle} from import {View} from 'react-native'; import type {ValueOf} from 'type-fest'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; @@ -558,7 +559,7 @@ function MenuItem({ shouldBeAccessible = true, tabIndex = 0, }: MenuItemProps) { - const icons = useMemoizedLazyExpensifyIcons(['ArrowRight', 'FallbackAvatar', 'DotIndicator', 'Checkmark']); + const icons = useMemoizedLazyExpensifyIcons(['ArrowRight', 'FallbackAvatar', 'DotIndicator', 'Checkmark', 'NewWindow']); const theme = useTheme(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); @@ -567,7 +568,7 @@ function MenuItem({ const {isExecuting, singleExecution, waitForNavigate} = useContext(MenuItemGroupContext) ?? {}; const popoverAnchor = useRef(null); const deviceHasHoverSupport = hasHoverSupport(); - + const {translate} = useLocalize(); const isCompact = viewMode === CONST.OPTION_MODE.COMPACT; const isDeleted = style && Array.isArray(style) ? style.includes(styles.offlineFeedbackDeleted) : false; const descriptionVerticalMargin = shouldShowDescriptionOnTop ? styles.mb1 : styles.mt1; @@ -708,6 +709,18 @@ function MenuItem({ const isIDPassed = !!iconReportID || !!iconAccountID || iconAccountID === CONST.DEFAULT_NUMBER_ID; + const isNewWindowIcon = useMemo(() => { + return iconRight === icons.NewWindow; + }, [iconRight, icons.NewWindow]); + + const enhancedAccessibilityLabel = useMemo(() => { + const result = accessibilityLabel ?? defaultAccessibilityLabel; + if (isNewWindowIcon) { + return `${result}. ${translate('common.opensInNewTab')}`; + } + return result; + }, [accessibilityLabel, defaultAccessibilityLabel, isNewWindowIcon, translate]); + return ( = { exchangeRate: 'Wechselkurs', reimbursableTotal: 'Erstattungsfähiger Gesamtbetrag', nonReimbursableTotal: 'Nicht erstattungsfähiger Gesamtbetrag', + opensInNewTab: 'Öffnet sich in einem neuen Tab', originalAmount: 'Ursprünglicher Betrag', insights: 'Einblicke', duplicateExpense: 'Doppelte Ausgabe', diff --git a/src/languages/en.ts b/src/languages/en.ts index 200e8335566f..e08edd14c04a 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -628,6 +628,7 @@ const translations = { exchangeRate: 'Exchange rate', reimbursableTotal: 'Reimbursable total', nonReimbursableTotal: 'Non-reimbursable total', + opensInNewTab: 'Opens in a new tab', }, supportalNoAccess: { title: 'Not so fast', diff --git a/src/languages/es.ts b/src/languages/es.ts index a988d3c80d67..23bd46c074ac 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -387,6 +387,7 @@ const translations: TranslationDeepObject = { exchangeRate: 'Tipo de cambio', reimbursableTotal: 'Total reembolsable', nonReimbursableTotal: 'Total no reembolsable', + opensInNewTab: 'Se abre en una nueva pestaña', }, supportalNoAccess: { title: 'No tan rápido', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index c80f7a1d5879..b37ded2ad213 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -635,6 +635,7 @@ const translations: TranslationDeepObject = { exchangeRate: 'Taux de change', reimbursableTotal: 'Total remboursable', nonReimbursableTotal: 'Total non remboursable', + opensInNewTab: 'S’ouvre dans un nouvel onglet', originalAmount: 'Montant d’origine', insights: 'Analyses', duplicateExpense: 'Note de frais en double', diff --git a/src/languages/it.ts b/src/languages/it.ts index c466864413ba..7e242b134fd8 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -634,6 +634,7 @@ const translations: TranslationDeepObject = { exchangeRate: 'Tasso di cambio', reimbursableTotal: 'Totale rimborsabile', nonReimbursableTotal: 'Totale non rimborsabile', + opensInNewTab: 'Si apre in una nuova scheda', originalAmount: 'Importo originale', insights: 'Analisi', duplicateExpense: 'Spesa duplicata', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index cbe74fca2668..cfc7f3ffe27c 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -633,6 +633,7 @@ const translations: TranslationDeepObject = { exchangeRate: '為替レート', reimbursableTotal: '経費精算対象の合計', nonReimbursableTotal: '非払い戻し合計', + opensInNewTab: '新しいタブで開きます', originalAmount: '元の金額', insights: 'インサイト', duplicateExpense: '重複した経費', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index d07a6f98c087..efbe9e39bf8a 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -634,6 +634,7 @@ const translations: TranslationDeepObject = { exchangeRate: 'Wisselkoers', reimbursableTotal: 'Totaal te vergoeden', nonReimbursableTotal: 'Niet-vergoedbaar totaal', + opensInNewTab: 'Opent in een nieuw tabblad', originalAmount: 'Oorspronkelijk bedrag', insights: 'Inzichten', duplicateExpense: 'Dubbele uitgave', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 08e2d8bb294c..c484b8c520cf 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -634,6 +634,7 @@ const translations: TranslationDeepObject = { exchangeRate: 'Kurs wymiany', reimbursableTotal: 'Łączna kwota podlegająca zwrotowi', nonReimbursableTotal: 'Suma niepodlegająca zwrotowi', + opensInNewTab: 'Otwiera się w nowej karcie', originalAmount: 'Kwota pierwotna', insights: 'Analizy', duplicateExpense: 'Zduplikowany wydatek', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index cfcc64ebc274..45535698f8aa 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -633,6 +633,7 @@ const translations: TranslationDeepObject = { exchangeRate: 'Taxa de câmbio', reimbursableTotal: 'Total reembolsável', nonReimbursableTotal: 'Total não reembolsável', + opensInNewTab: 'Abre em uma nova aba', originalAmount: 'Valor original', insights: 'Insights', duplicateExpense: 'Despesa duplicada', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index c38247687c9a..7a63a9449f45 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -630,6 +630,7 @@ const translations: TranslationDeepObject = { exchangeRate: '汇率', reimbursableTotal: '可报销总额', nonReimbursableTotal: '不可报销总额', + opensInNewTab: '在新标签页中打开', originalAmount: '原始金额', insights: '洞察', duplicateExpense: '重复报销', From c75936970ac38136196df0e2984d1009860fe394 Mon Sep 17 00:00:00 2001 From: nkdengineer Date: Fri, 30 Jan 2026 21:55:39 +0700 Subject: [PATCH 2/4] remove useMemo --- src/components/MenuItem.tsx | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/src/components/MenuItem.tsx b/src/components/MenuItem.tsx index 50431dc4abba..bbaa901ecf77 100644 --- a/src/components/MenuItem.tsx +++ b/src/components/MenuItem.tsx @@ -713,17 +713,11 @@ function MenuItem({ const isIDPassed = !!iconReportID || !!iconAccountID || iconAccountID === CONST.DEFAULT_NUMBER_ID; - const isNewWindowIcon = useMemo(() => { - return iconRight === icons.NewWindow; - }, [iconRight, icons.NewWindow]); - - const enhancedAccessibilityLabel = useMemo(() => { - const result = accessibilityLabel ?? defaultAccessibilityLabel; - if (isNewWindowIcon) { - return `${result}. ${translate('common.opensInNewTab')}`; - } - return result; - }, [accessibilityLabel, defaultAccessibilityLabel, isNewWindowIcon, translate]); + const isNewWindowIcon = iconRight === icons.NewWindow; + let enhancedAccessibilityLabel = accessibilityLabel ?? defaultAccessibilityLabel; + if (isNewWindowIcon) { + enhancedAccessibilityLabel = `${enhancedAccessibilityLabel}. ${translate('common.opensInNewTab')}`; + } return ( Date: Wed, 11 Feb 2026 15:33:13 +0700 Subject: [PATCH 3/4] complete resolve conflict --- src/languages/de.ts | 2 +- src/languages/pt-BR.ts | 3 --- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/src/languages/de.ts b/src/languages/de.ts index 5eeb0bc24842..ed5313fd2b3e 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -592,7 +592,7 @@ const translations: TranslationDeepObject = { exchangeRate: 'Wechselkurs', reimbursableTotal: 'Erstattungsfähiger Gesamtbetrag', nonReimbursableTotal: 'Nicht erstattungsfähige Gesamtsumme', - opensInNewTab: 'Öffnet sich in einem neuen Tab', + opensInNewTab: 'Wird in einem neuen Tab geöffnet', locked: 'Gesperrt', month: 'Monat', week: 'Woche', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index a6ebff5fc771..feb4c28ba155 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -591,11 +591,8 @@ const translations: TranslationDeepObject = { exchangeRate: 'Taxa de câmbio', reimbursableTotal: 'Total reembolsável', nonReimbursableTotal: 'Total não reembolsável', -<<<<<<< HEAD opensInNewTab: 'Abre em uma nova aba', -======= locked: 'Bloqueado', ->>>>>>> main month: 'Mês', week: 'Semana', year: 'Ano', From 980b64d4907503cf89f00b2c3fd7f23ae2366808 Mon Sep 17 00:00:00 2001 From: nkdengineer Date: Sun, 22 Feb 2026 20:52:10 +0700 Subject: [PATCH 4/4] add unit test --- tests/ui/components/MenuItemTest.tsx | 86 ++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 tests/ui/components/MenuItemTest.tsx diff --git a/tests/ui/components/MenuItemTest.tsx b/tests/ui/components/MenuItemTest.tsx new file mode 100644 index 000000000000..7db09cde04bf --- /dev/null +++ b/tests/ui/components/MenuItemTest.tsx @@ -0,0 +1,86 @@ +import {render, screen} from '@testing-library/react-native'; +import React from 'react'; +import type {SvgProps} from 'react-native-svg'; +import * as Expensicons from '@components/Icon/Expensicons'; +import {LocaleContextProvider} from '@components/LocaleContextProvider'; +import MenuItem from '@components/MenuItem'; +import {translateLocal} from '../../utils/TestHelper'; + +// Mock useLazyAsset hook +jest.mock('@hooks/useLazyAsset', () => { + const ExpensiconsModule = jest.requireActual>>('@components/Icon/Expensicons'); + return { + useMemoizedLazyExpensifyIcons: jest.fn(() => ({ + NewWindow: ExpensiconsModule.NewWindow, + })), + }; +}); + +function Wrapper({children}: {children: React.ReactNode}) { + return {children}; +} + +describe('MenuItem', () => { + describe('accessibility label with NewWindow icon', () => { + it('appends "Opens in a new tab" to the accessibility label when iconRight is NewWindow', () => { + const customLabel = 'Open external link'; + const opensInNewTabText = translateLocal('common.opensInNewTab'); + const expectedAccessibilityLabel = `${customLabel}. ${opensInNewTabText}`; + + render( + + {}} + /> + , + ); + + const menuItem = screen.getByLabelText(expectedAccessibilityLabel); + expect(menuItem).toBeOnTheScreen(); + }); + + it('uses default accessibility label (from title) with "Opens in a new tab" when iconRight is NewWindow and no accessibilityLabel is passed', () => { + const title = 'Download app'; + const opensInNewTabText = translateLocal('common.opensInNewTab'); + const expectedAccessibilityLabel = `${title}. ${opensInNewTabText}`; + + render( + + {}} + /> + , + ); + + const menuItem = screen.getByLabelText(expectedAccessibilityLabel); + expect(menuItem).toBeOnTheScreen(); + }); + + it('does not append "Opens in a new tab" when iconRight is not NewWindow', () => { + const customLabel = 'Go to next'; + + render( + + {}} + /> + , + ); + + const menuItem = screen.getByLabelText(customLabel); + expect(menuItem).toBeOnTheScreen(); + expect(screen.queryByLabelText(`${customLabel}. ${translateLocal('common.opensInNewTab')}`)).not.toBeOnTheScreen(); + }); + }); +});