Skip to content

Commit 735fc24

Browse files
authored
Merge pull request #19 from TaduJR/fix/android-headless-talkback-accessibility
fix: android-headless-talkback-accessibility
2 parents c48d6b7 + 8be9c8f commit 735fc24

File tree

2 files changed

+181
-1
lines changed

2 files changed

+181
-1
lines changed

src/index.js

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,7 @@ export default class RNPickerSelect extends PureComponent {
165165
this.scrollToInput = this.scrollToInput.bind(this);
166166
this.togglePicker = this.togglePicker.bind(this);
167167
this.renderInputAccessoryView = this.renderInputAccessoryView.bind(this);
168+
this.androidPickerRef = React.createRef();
168169
}
169170

170171
componentDidUpdate = (prevProps, prevState) => {
@@ -579,16 +580,41 @@ export default class RNPickerSelect extends PureComponent {
579580
const { selectedItem } = this.state;
580581

581582
const Component = fixAndroidTouchableBug ? View : TouchableOpacity;
583+
const pickerRef = (pickerProps && pickerProps.ref) || this.androidPickerRef;
584+
585+
const handleAccessibilityAction = (event) => {
586+
if (disabled) {
587+
return;
588+
}
589+
if (event.nativeEvent.actionName === 'activate') {
590+
if (pickerRef && pickerRef.current && pickerRef.current.focus) {
591+
pickerRef.current.focus();
592+
}
593+
}
594+
};
595+
596+
const accessibilityLabel = pickerProps && pickerProps.accessibilityLabel;
597+
582598
return (
583599
<Component
584600
testID="android_touchable_wrapper"
585601
onPress={onOpen}
586602
activeOpacity={1}
587603
{...touchableWrapperProps}
604+
accessible
605+
accessibilityRole="combobox"
606+
accessibilityLabel={accessibilityLabel}
607+
accessibilityState={{ disabled }}
608+
onAccessibilityAction={handleAccessibilityAction}
609+
accessibilityActions={[{ name: 'activate' }]}
588610
>
589-
<View style={style.headlessAndroidContainer}>
611+
<View
612+
style={style.headlessAndroidContainer}
613+
importantForAccessibility="no-hide-descendants"
614+
>
590615
{this.renderTextInputOrChildren()}
591616
<Picker
617+
ref={pickerRef}
592618
style={[
593619
Icon ? { backgroundColor: 'transparent' } : {}, // to hide native icon
594620
defaultStyles.headlessAndroidPicker,

test/test.js

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,160 @@ describe('RNPickerSelect', () => {
404404
expect(touchable.type().displayName).toEqual('View');
405405
});
406406

407+
describe('Android headless mode accessibility', () => {
408+
beforeEach(() => {
409+
Platform.OS = 'android';
410+
});
411+
412+
it('should have accessibility props on the wrapper (Android headless)', () => {
413+
const wrapper = shallow(
414+
<RNPickerSelect
415+
items={selectItems}
416+
onValueChange={noop}
417+
useNativeAndroidPickerStyle={false}
418+
pickerProps={{
419+
accessibilityLabel: 'Select an item',
420+
}}
421+
/>
422+
);
423+
424+
const touchable = wrapper.find('[testID="android_touchable_wrapper"]');
425+
426+
expect(touchable.props().accessible).toEqual(true);
427+
expect(touchable.props().accessibilityRole).toEqual('combobox');
428+
expect(touchable.props().accessibilityLabel).toEqual('Select an item');
429+
expect(touchable.props().accessibilityState).toEqual({ disabled: false });
430+
expect(touchable.props().accessibilityActions).toEqual([{ name: 'activate' }]);
431+
expect(touchable.props().onAccessibilityAction).toBeDefined();
432+
});
433+
434+
it('should use accessibilityLabel from pickerProps (Android headless)', () => {
435+
const wrapper = shallow(
436+
<RNPickerSelect
437+
items={selectItems}
438+
placeholder={{}}
439+
onValueChange={noop}
440+
useNativeAndroidPickerStyle={false}
441+
value="orange"
442+
pickerProps={{
443+
accessibilityLabel: 'Choose a color',
444+
}}
445+
/>
446+
);
447+
448+
const touchable = wrapper.find('[testID="android_touchable_wrapper"]');
449+
450+
expect(touchable.props().accessibilityLabel).toEqual('Choose a color');
451+
});
452+
453+
it('should have undefined accessibilityLabel when not provided via pickerProps (Android headless)', () => {
454+
const wrapper = shallow(
455+
<RNPickerSelect
456+
items={selectItems}
457+
placeholder={{}}
458+
onValueChange={noop}
459+
useNativeAndroidPickerStyle={false}
460+
value="orange"
461+
/>
462+
);
463+
464+
const touchable = wrapper.find('[testID="android_touchable_wrapper"]');
465+
466+
expect(touchable.props().accessibilityLabel).toBeUndefined();
467+
});
468+
469+
it('should have importantForAccessibility on inner container (Android headless)', () => {
470+
const wrapper = shallow(
471+
<RNPickerSelect
472+
items={selectItems}
473+
onValueChange={noop}
474+
useNativeAndroidPickerStyle={false}
475+
/>
476+
);
477+
478+
const touchable = wrapper.find('[testID="android_touchable_wrapper"]');
479+
const innerContainer = touchable.children().first();
480+
481+
expect(innerContainer.props().importantForAccessibility).toEqual('no-hide-descendants');
482+
});
483+
484+
it('should not trigger picker when disabled and accessibility action is called (Android headless)', () => {
485+
const wrapper = shallow(
486+
<RNPickerSelect
487+
items={selectItems}
488+
onValueChange={noop}
489+
useNativeAndroidPickerStyle={false}
490+
disabled
491+
/>
492+
);
493+
494+
const touchable = wrapper.find('[testID="android_touchable_wrapper"]');
495+
const onAccessibilityAction = touchable.props().onAccessibilityAction;
496+
497+
// This should not throw and should be a no-op when disabled
498+
expect(() => {
499+
onAccessibilityAction({ nativeEvent: { actionName: 'activate' } });
500+
}).not.toThrow();
501+
});
502+
503+
it('should set accessibilityState.disabled to true when disabled (Android headless)', () => {
504+
const wrapper = shallow(
505+
<RNPickerSelect
506+
items={selectItems}
507+
onValueChange={noop}
508+
useNativeAndroidPickerStyle={false}
509+
disabled
510+
/>
511+
);
512+
513+
const touchable = wrapper.find('[testID="android_touchable_wrapper"]');
514+
515+
expect(touchable.props().accessibilityState).toEqual({ disabled: true });
516+
});
517+
518+
it('should call pickerRef.focus() when accessibility action "activate" is triggered (Android headless)', () => {
519+
const mockFocus = jest.fn();
520+
const mockRef = { current: { focus: mockFocus } };
521+
522+
const wrapper = shallow(
523+
<RNPickerSelect
524+
items={selectItems}
525+
onValueChange={noop}
526+
useNativeAndroidPickerStyle={false}
527+
pickerProps={{ ref: mockRef }}
528+
/>
529+
);
530+
531+
const touchable = wrapper.find('[testID="android_touchable_wrapper"]');
532+
const onAccessibilityAction = touchable.props().onAccessibilityAction;
533+
534+
onAccessibilityAction({ nativeEvent: { actionName: 'activate' } });
535+
536+
expect(mockFocus).toHaveBeenCalledTimes(1);
537+
});
538+
539+
it('should not call pickerRef.focus() for non-activate actions (Android headless)', () => {
540+
const mockFocus = jest.fn();
541+
const mockRef = { current: { focus: mockFocus } };
542+
543+
const wrapper = shallow(
544+
<RNPickerSelect
545+
items={selectItems}
546+
onValueChange={noop}
547+
useNativeAndroidPickerStyle={false}
548+
pickerProps={{ ref: mockRef }}
549+
/>
550+
);
551+
552+
const touchable = wrapper.find('[testID="android_touchable_wrapper"]');
553+
const onAccessibilityAction = touchable.props().onAccessibilityAction;
554+
555+
onAccessibilityAction({ nativeEvent: { actionName: 'longpress' } });
556+
557+
expect(mockFocus).not.toHaveBeenCalled();
558+
});
559+
});
560+
407561
it('should call the onClose callback when set', () => {
408562
Platform.OS = 'ios';
409563
const onCloseSpy = jest.fn();

0 commit comments

Comments
 (0)