From ae60c9740a3a3c345ef099c525d8370d8aebc9fb Mon Sep 17 00:00:00 2001 From: "Qichen Zhu (via MelvinBot)" Date: Thu, 26 Mar 2026 23:23:12 +0000 Subject: [PATCH 1/3] Add aria-expanded to ButtonWithDropdownMenu for screen reader accessibility - Map accessibilityState.expanded to aria-expanded in BaseGenericPressable - Add accessibilityState prop to Button component - Pass expanded state from ButtonWithDropdownMenu to its trigger buttons - Patch React Native Fabric to announce both expanded and collapsed states on iOS Co-authored-by: Qichen Zhu --- patches/react-native/details.md | 6 +++++ ...collapsed-accessibility-announcement.patch | 23 +++++++++++++++++++ src/components/Button/index.tsx | 7 +++++- .../ButtonWithDropdownMenu/index.tsx | 2 ++ .../implementation/BaseGenericPressable.tsx | 1 + 5 files changed, 38 insertions(+), 1 deletion(-) create mode 100644 patches/react-native/react-native+0.83.1+035+fix-fabric-collapsed-accessibility-announcement.patch diff --git a/patches/react-native/details.md b/patches/react-native/details.md index 58b39efdb5f3..1fae78bf2e69 100644 --- a/patches/react-native/details.md +++ b/patches/react-native/details.md @@ -258,3 +258,9 @@ - Reason: Fixes an Android-specific issue (reproducible on certain Samsung models) where `onPress` events do not trigger for `Pressable` components when used inside a `Tooltip`. The root cause is that in the new architecture, `Pressability.measure()` reads stale layout information from the shadow tree instead of the actual native view hierarchy. This patch introduces a new `measureAsyncOnUI` method that measures the view asynchronously using the native layout hierarchy on the UI thread, bypassing stale shadow tree data. - Upstream PR/issue: [facebook/react-native#51835](https://github.com/facebook/react-native/pull/51835) - E/App issue: [#59953](https://github.com/Expensify/App/issues/59953) + +### [react-native+0.83.1+035+fix-fabric-collapsed-accessibility-announcement.patch](react-native+0.83.1+035+fix-fabric-collapsed-accessibility-announcement.patch) + +- Reason: Fixes a Fabric regression where VoiceOver on iOS only announces "expanded" but never "collapsed" for elements with `accessibilityState.expanded`. In `RCTViewComponentView.mm`, the code uses `value_or(false)` which skips the announcement entirely when `expanded` is `false`. This patch changes the logic to use `has_value()` and correctly announce both "expanded" and "collapsed" states, matching the old architecture (Paper) behavior. +- Upstream PR/issue: 🛑 +- E/App issue: [#76929](https://github.com/Expensify/App/issues/76929) diff --git a/patches/react-native/react-native+0.83.1+035+fix-fabric-collapsed-accessibility-announcement.patch b/patches/react-native/react-native+0.83.1+035+fix-fabric-collapsed-accessibility-announcement.patch new file mode 100644 index 000000000000..6cc2068ebb0f --- /dev/null +++ b/patches/react-native/react-native+0.83.1+035+fix-fabric-collapsed-accessibility-announcement.patch @@ -0,0 +1,23 @@ +diff --git a/node_modules/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm b/node_modules/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm +--- a/node_modules/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm ++++ b/node_modules/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm +@@ -1440,9 +1440,16 @@ + addObject:RCTLocalizedString( + "mixed", "a checkbox, radio button, or other widget which is both checked and unchecked")]; + } +- if (accessibilityState.expanded.value_or(false)) { +- [valueComponents +- addObject:RCTLocalizedString("expanded", "a menu, dialog, accordian panel, or other widget which is expanded")]; ++ if (accessibilityState.expanded.has_value()) { ++ if (accessibilityState.expanded.value()) { ++ [valueComponents ++ addObject:RCTLocalizedString( ++ "expanded", "a menu, dialog, accordian panel, or other widget which is expanded")]; ++ } else { ++ [valueComponents ++ addObject:RCTLocalizedString( ++ "collapsed", "a menu, dialog, accordian panel, or other widget which is collapsed")]; ++ } + } + + if (accessibilityState.busy) { diff --git a/src/components/Button/index.tsx b/src/components/Button/index.tsx index 03ac1e14694e..b75bc5ac26a2 100644 --- a/src/components/Button/index.tsx +++ b/src/components/Button/index.tsx @@ -1,7 +1,7 @@ import {useIsFocused} from '@react-navigation/native'; import type {ForwardedRef} from 'react'; import React, {useCallback, useMemo, useState} from 'react'; -import type {GestureResponderEvent, LayoutChangeEvent, StyleProp, TextStyle, ViewStyle} from 'react-native'; +import type {AccessibilityState, GestureResponderEvent, LayoutChangeEvent, StyleProp, TextStyle, ViewStyle} from 'react-native'; import {StyleSheet, View} from 'react-native'; import ActivityIndicator from '@components/ActivityIndicator'; import Icon from '@components/Icon'; @@ -149,6 +149,9 @@ type ButtonProps = Partial & /** Accessibility label for the component */ accessibilityLabel?: string; + /** Accessibility state to pass to the pressable */ + accessibilityState?: AccessibilityState; + /** The text for the button label */ text?: string; @@ -291,6 +294,7 @@ function Button({ secondLineText = '', shouldBlendOpacity = false, shouldStayNormalOnDisable = false, + accessibilityState, sentryLabel, ref, ...rest @@ -527,6 +531,7 @@ function Button({ id={id} testID={testID} accessibilityLabel={accessibilityLabel} + accessibilityState={accessibilityState} role={getButtonRole(isNested)} hoverDimmingValue={1} onHoverIn={!isDisabled || !shouldStayNormalOnDisable ? () => setIsHovered(true) : undefined} diff --git a/src/components/ButtonWithDropdownMenu/index.tsx b/src/components/ButtonWithDropdownMenu/index.tsx index e609e656e897..18c4811b6bb7 100644 --- a/src/components/ButtonWithDropdownMenu/index.tsx +++ b/src/components/ButtonWithDropdownMenu/index.tsx @@ -172,6 +172,7 @@ function ButtonWithDropdownMenu({ref, ...props}: ButtonWithDropdownM ref={dropdownButtonRef} onPress={handlePress} text={customText ?? selectedItem?.text ?? ''} + accessibilityState={!isSplitButton ? {expanded: isMenuVisible} : undefined} isDisabled={isDisabled || areAllOptionsDisabled} shouldStayNormalOnDisable={shouldStayNormalOnDisable} isLoading={isLoading} @@ -202,6 +203,7 @@ function ButtonWithDropdownMenu({ref, ...props}: ButtonWithDropdownM ref={dropdownAnchor} success={success} isDisabled={isDisabled} + accessibilityState={{expanded: isMenuVisible}} shouldStayNormalOnDisable={shouldStayNormalOnDisable} style={[styles.pl0]} onPress={() => setIsMenuVisible(!isMenuVisible)} diff --git a/src/components/Pressable/GenericPressable/implementation/BaseGenericPressable.tsx b/src/components/Pressable/GenericPressable/implementation/BaseGenericPressable.tsx index b62140832848..2249726d62dc 100644 --- a/src/components/Pressable/GenericPressable/implementation/BaseGenericPressable.tsx +++ b/src/components/Pressable/GenericPressable/implementation/BaseGenericPressable.tsx @@ -202,6 +202,7 @@ function GenericPressable({ aria-disabled={isDisabled} aria-checked={accessibilityState?.checked} aria-selected={accessibilityState?.selected} + aria-expanded={accessibilityState?.expanded} aria-keyshortcuts={keyboardShortcut && `${keyboardShortcut.modifiers.join('')}+${keyboardShortcut.shortcutKey}`} // ios-only form of inputs onMagicTap={!isDisabled ? voidOnPressHandler : undefined} From eda70c6f4fe7ba73c442e0fa639bb0ec44c526f8 Mon Sep 17 00:00:00 2001 From: "Qichen Zhu (via MelvinBot)" Date: Tue, 31 Mar 2026 19:43:25 +0000 Subject: [PATCH 2/3] Remove stale 034 patch entry and renumber 035 to 034 The react-native+0.83.1+034+fix-pressability-new-arch.patch was deleted on main, so the documentation entry for it should not be in this PR. Renumbered the accessibility patch from 035 to 034 to fill the gap. Co-authored-by: Qichen Zhu --- patches/react-native/details.md | 8 +------- ...fix-fabric-collapsed-accessibility-announcement.patch} | 0 2 files changed, 1 insertion(+), 7 deletions(-) rename patches/react-native/{react-native+0.83.1+035+fix-fabric-collapsed-accessibility-announcement.patch => react-native+0.83.1+034+fix-fabric-collapsed-accessibility-announcement.patch} (100%) diff --git a/patches/react-native/details.md b/patches/react-native/details.md index 49769b33d605..f701d2ed1037 100644 --- a/patches/react-native/details.md +++ b/patches/react-native/details.md @@ -254,13 +254,7 @@ - PR introducing patch: 🛑 -### [react-native+0.83.1+034+fix-pressability-new-arch.patch](react-native+0.83.1+034+fix-pressability-new-arch.patch) - -- Reason: Fixes an Android-specific issue (reproducible on certain Samsung models) where `onPress` events do not trigger for `Pressable` components when used inside a `Tooltip`. The root cause is that in the new architecture, `Pressability.measure()` reads stale layout information from the shadow tree instead of the actual native view hierarchy. This patch introduces a new `measureAsyncOnUI` method that measures the view asynchronously using the native layout hierarchy on the UI thread, bypassing stale shadow tree data. -- Upstream PR/issue: [facebook/react-native#51835](https://github.com/facebook/react-native/pull/51835) -- E/App issue: [#59953](https://github.com/Expensify/App/issues/59953) - -### [react-native+0.83.1+035+fix-fabric-collapsed-accessibility-announcement.patch](react-native+0.83.1+035+fix-fabric-collapsed-accessibility-announcement.patch) +### [react-native+0.83.1+034+fix-fabric-collapsed-accessibility-announcement.patch](react-native+0.83.1+034+fix-fabric-collapsed-accessibility-announcement.patch) - Reason: Fixes a Fabric regression where VoiceOver on iOS only announces "expanded" but never "collapsed" for elements with `accessibilityState.expanded`. In `RCTViewComponentView.mm`, the code uses `value_or(false)` which skips the announcement entirely when `expanded` is `false`. This patch changes the logic to use `has_value()` and correctly announce both "expanded" and "collapsed" states, matching the old architecture (Paper) behavior. - Upstream PR/issue: 🛑 diff --git a/patches/react-native/react-native+0.83.1+035+fix-fabric-collapsed-accessibility-announcement.patch b/patches/react-native/react-native+0.83.1+034+fix-fabric-collapsed-accessibility-announcement.patch similarity index 100% rename from patches/react-native/react-native+0.83.1+035+fix-fabric-collapsed-accessibility-announcement.patch rename to patches/react-native/react-native+0.83.1+034+fix-fabric-collapsed-accessibility-announcement.patch From 175103f0bac9d1ec73773690e493b10813b83281 Mon Sep 17 00:00:00 2001 From: "Qichen Zhu (via MelvinBot)" Date: Tue, 31 Mar 2026 21:52:58 +0000 Subject: [PATCH 3/3] Include upstream issue link and remove extra blank line in patch details Co-authored-by: Qichen Zhu --- patches/react-native/details.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/patches/react-native/details.md b/patches/react-native/details.md index f701d2ed1037..6503468ae620 100644 --- a/patches/react-native/details.md +++ b/patches/react-native/details.md @@ -253,9 +253,8 @@ - E/App issue: https://github.com/Expensify/App/issues/85877 - PR introducing patch: 🛑 - ### [react-native+0.83.1+034+fix-fabric-collapsed-accessibility-announcement.patch](react-native+0.83.1+034+fix-fabric-collapsed-accessibility-announcement.patch) - Reason: Fixes a Fabric regression where VoiceOver on iOS only announces "expanded" but never "collapsed" for elements with `accessibilityState.expanded`. In `RCTViewComponentView.mm`, the code uses `value_or(false)` which skips the announcement entirely when `expanded` is `false`. This patch changes the logic to use `has_value()` and correctly announce both "expanded" and "collapsed" states, matching the old architecture (Paper) behavior. -- Upstream PR/issue: 🛑 +- Upstream PR/issue: https://github.com/facebook/react-native/issues/56296 - E/App issue: [#76929](https://github.com/Expensify/App/issues/76929)