From 9a282789f65d4f5439d064ba59f3a33f59d2fe4b Mon Sep 17 00:00:00 2001 From: TaduJR Date: Thu, 22 Jan 2026 06:44:06 +0300 Subject: [PATCH] fix: android-headless-talkback-accessibility --- src/index.js | 28 +++++++++- test/test.js | 150 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 177 insertions(+), 1 deletion(-) diff --git a/src/index.js b/src/index.js index aef27192..65aea67b 100644 --- a/src/index.js +++ b/src/index.js @@ -165,6 +165,7 @@ export default class RNPickerSelect extends PureComponent { this.scrollToInput = this.scrollToInput.bind(this); this.togglePicker = this.togglePicker.bind(this); this.renderInputAccessoryView = this.renderInputAccessoryView.bind(this); + this.androidPickerRef = React.createRef(); } componentDidUpdate = (prevProps, prevState) => { @@ -579,16 +580,41 @@ export default class RNPickerSelect extends PureComponent { const { selectedItem } = this.state; const Component = fixAndroidTouchableBug ? View : TouchableOpacity; + const pickerRef = (pickerProps && pickerProps.ref) || this.androidPickerRef; + + const handleAccessibilityAction = (event) => { + if (disabled) { + return; + } + if (event.nativeEvent.actionName === 'activate') { + if (pickerRef && pickerRef.current && pickerRef.current.focus) { + pickerRef.current.focus(); + } + } + }; + + const accessibilityLabel = selectedItem.inputLabel || selectedItem.label; + return ( - + {this.renderTextInputOrChildren()} { expect(touchable.type().displayName).toEqual('View'); }); + describe('Android headless mode accessibility', () => { + beforeEach(() => { + Platform.OS = 'android'; + }); + + it('should have accessibility props on the wrapper (Android headless)', () => { + const wrapper = shallow( + + ); + + const touchable = wrapper.find('[testID="android_touchable_wrapper"]'); + + expect(touchable.props().accessible).toEqual(true); + expect(touchable.props().accessibilityRole).toEqual('combobox'); + // Default placeholder label is "Select an item..." + expect(touchable.props().accessibilityLabel).toEqual('Select an item...'); + expect(touchable.props().accessibilityState).toEqual({ disabled: false }); + expect(touchable.props().accessibilityActions).toEqual([{ name: 'activate' }]); + expect(touchable.props().onAccessibilityAction).toBeDefined(); + }); + + it('should use selectedItem label as accessibilityLabel (Android headless)', () => { + const wrapper = shallow( + + ); + + const touchable = wrapper.find('[testID="android_touchable_wrapper"]'); + + expect(touchable.props().accessibilityLabel).toEqual('Orange'); + }); + + it('should use inputLabel as accessibilityLabel when provided (Android headless)', () => { + const itemsWithInputLabel = [{ label: 'Red', value: 'red', inputLabel: 'RED COLOR' }]; + const wrapper = shallow( + + ); + + const touchable = wrapper.find('[testID="android_touchable_wrapper"]'); + + expect(touchable.props().accessibilityLabel).toEqual('RED COLOR'); + }); + + it('should have importantForAccessibility on inner container (Android headless)', () => { + const wrapper = shallow( + + ); + + const touchable = wrapper.find('[testID="android_touchable_wrapper"]'); + const innerContainer = touchable.children().first(); + + expect(innerContainer.props().importantForAccessibility).toEqual('no-hide-descendants'); + }); + + it('should not trigger picker when disabled and accessibility action is called (Android headless)', () => { + const wrapper = shallow( + + ); + + const touchable = wrapper.find('[testID="android_touchable_wrapper"]'); + const onAccessibilityAction = touchable.props().onAccessibilityAction; + + // This should not throw and should be a no-op when disabled + expect(() => { + onAccessibilityAction({ nativeEvent: { actionName: 'activate' } }); + }).not.toThrow(); + }); + + it('should set accessibilityState.disabled to true when disabled (Android headless)', () => { + const wrapper = shallow( + + ); + + const touchable = wrapper.find('[testID="android_touchable_wrapper"]'); + + expect(touchable.props().accessibilityState).toEqual({ disabled: true }); + }); + + it('should call pickerRef.focus() when accessibility action "activate" is triggered (Android headless)', () => { + const mockFocus = jest.fn(); + const mockRef = { current: { focus: mockFocus } }; + + const wrapper = shallow( + + ); + + const touchable = wrapper.find('[testID="android_touchable_wrapper"]'); + const onAccessibilityAction = touchable.props().onAccessibilityAction; + + onAccessibilityAction({ nativeEvent: { actionName: 'activate' } }); + + expect(mockFocus).toHaveBeenCalledTimes(1); + }); + + it('should not call pickerRef.focus() for non-activate actions (Android headless)', () => { + const mockFocus = jest.fn(); + const mockRef = { current: { focus: mockFocus } }; + + const wrapper = shallow( + + ); + + const touchable = wrapper.find('[testID="android_touchable_wrapper"]'); + const onAccessibilityAction = touchable.props().onAccessibilityAction; + + onAccessibilityAction({ nativeEvent: { actionName: 'longpress' } }); + + expect(mockFocus).not.toHaveBeenCalled(); + }); + }); + it('should call the onClose callback when set', () => { Platform.OS = 'ios'; const onCloseSpy = jest.fn();