diff --git a/patches/react-native/details.md b/patches/react-native/details.md index fdbef63a1b16..6503468ae620 100644 --- a/patches/react-native/details.md +++ b/patches/react-native/details.md @@ -252,3 +252,9 @@ - Upstream PR/issue: 🛑 - 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: https://github.com/facebook/react-native/issues/56296 +- E/App issue: [#76929](https://github.com/Expensify/App/issues/76929) diff --git a/patches/react-native/react-native+0.83.1+034+fix-fabric-collapsed-accessibility-announcement.patch b/patches/react-native/react-native+0.83.1+034+fix-fabric-collapsed-accessibility-announcement.patch new file mode 100644 index 000000000000..6cc2068ebb0f --- /dev/null +++ b/patches/react-native/react-native+0.83.1+034+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}