Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 27 additions & 1 deletion src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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 = pickerProps && pickerProps.accessibilityLabel;

return (
<Component
testID="android_touchable_wrapper"
onPress={onOpen}
activeOpacity={1}
{...touchableWrapperProps}
accessible
accessibilityRole="combobox"
accessibilityLabel={accessibilityLabel}
accessibilityState={{ disabled }}
onAccessibilityAction={handleAccessibilityAction}
accessibilityActions={[{ name: 'activate' }]}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we add support for 'escape' action too?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The iOS does not have this functionality, so adding for Android would make it inconsistent IMO. But if it's need we can do a separate follow-up PR.

>
<View style={style.headlessAndroidContainer}>
<View
style={style.headlessAndroidContainer}
importantForAccessibility="no-hide-descendants"
>
{this.renderTextInputOrChildren()}
<Picker
ref={pickerRef}
style={[
Icon ? { backgroundColor: 'transparent' } : {}, // to hide native icon
defaultStyles.headlessAndroidPicker,
Expand Down
154 changes: 154 additions & 0 deletions test/test.js
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,160 @@ describe('RNPickerSelect', () => {
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(
<RNPickerSelect
items={selectItems}
onValueChange={noop}
useNativeAndroidPickerStyle={false}
pickerProps={{
accessibilityLabel: 'Select an item',
}}
/>
);

const touchable = wrapper.find('[testID="android_touchable_wrapper"]');

expect(touchable.props().accessible).toEqual(true);
expect(touchable.props().accessibilityRole).toEqual('combobox');
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 accessibilityLabel from pickerProps (Android headless)', () => {
const wrapper = shallow(
<RNPickerSelect
items={selectItems}
placeholder={{}}
onValueChange={noop}
useNativeAndroidPickerStyle={false}
value="orange"
pickerProps={{
accessibilityLabel: 'Choose a color',
}}
/>
);

const touchable = wrapper.find('[testID="android_touchable_wrapper"]');

expect(touchable.props().accessibilityLabel).toEqual('Choose a color');
});

it('should have undefined accessibilityLabel when not provided via pickerProps (Android headless)', () => {
const wrapper = shallow(
<RNPickerSelect
items={selectItems}
placeholder={{}}
onValueChange={noop}
useNativeAndroidPickerStyle={false}
value="orange"
/>
);

const touchable = wrapper.find('[testID="android_touchable_wrapper"]');

expect(touchable.props().accessibilityLabel).toBeUndefined();
});

it('should have importantForAccessibility on inner container (Android headless)', () => {
const wrapper = shallow(
<RNPickerSelect
items={selectItems}
onValueChange={noop}
useNativeAndroidPickerStyle={false}
/>
);

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(
<RNPickerSelect
items={selectItems}
onValueChange={noop}
useNativeAndroidPickerStyle={false}
disabled
/>
);

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(
<RNPickerSelect
items={selectItems}
onValueChange={noop}
useNativeAndroidPickerStyle={false}
disabled
/>
);

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(
<RNPickerSelect
items={selectItems}
onValueChange={noop}
useNativeAndroidPickerStyle={false}
pickerProps={{ ref: mockRef }}
/>
);

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(
<RNPickerSelect
items={selectItems}
onValueChange={noop}
useNativeAndroidPickerStyle={false}
pickerProps={{ ref: mockRef }}
/>
);

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();
Expand Down