Skip to content

Commit 9a28278

Browse files
committed
fix: android-headless-talkback-accessibility
1 parent 07d60d7 commit 9a28278

2 files changed

Lines changed: 177 additions & 1 deletion

File tree

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

0 commit comments

Comments
 (0)