lawnstarter/react-native-picker-select

View on GitHub
src/index.js

Summary

Maintainability
D
1 day
Test Coverage
A
95%
import { Picker } from '@react-native-picker/picker';
import isEqual from 'lodash.isequal';
import isObject from 'lodash.isobject';
import PropTypes from 'prop-types';
import React, { PureComponent } from 'react';
import { Keyboard, Modal, Platform, Text, TextInput, TouchableOpacity, View } from 'react-native';
import { defaultStyles } from './styles';

export default class RNPickerSelect extends PureComponent {
  static propTypes = {
    onValueChange: PropTypes.func.isRequired,
    items: PropTypes.arrayOf(
      PropTypes.shape({
        label: PropTypes.string.isRequired,
        value: PropTypes.any.isRequired,
        testID: PropTypes.string,
        inputLabel: PropTypes.string,
        key: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
        color: PropTypes.string,
      })
    ).isRequired,
    value: PropTypes.any,
    placeholder: PropTypes.shape({
      label: PropTypes.string,
      value: PropTypes.any,
      key: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
      color: PropTypes.string,
    }),
    disabled: PropTypes.bool,
    itemKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
    style: PropTypes.shape({}),
    children: PropTypes.any,
    onOpen: PropTypes.func,
    useNativeAndroidPickerStyle: PropTypes.bool,
    fixAndroidTouchableBug: PropTypes.bool,
    darkTheme: PropTypes.bool,

    // Custom Modal props (iOS only)
    doneText: PropTypes.string,
    onDonePress: PropTypes.func,
    onUpArrow: PropTypes.func,
    onDownArrow: PropTypes.func,
    onClose: PropTypes.func,

    // Modal props (iOS only)
    modalProps: PropTypes.shape({}),

    // TextInput props
    textInputProps: PropTypes.shape({}),

    // Picker props
    pickerProps: PropTypes.shape({}),

    // Touchable Done props (iOS only)
    touchableDoneProps: PropTypes.shape({}),

    // Touchable wrapper props
    touchableWrapperProps: PropTypes.shape({}),

    // Custom Icon
    Icon: PropTypes.func,
    InputAccessoryView: PropTypes.func,
  };

  static defaultProps = {
    value: undefined,
    placeholder: {
      label: 'Select an item...',
      value: null,
      color: '#9EA0A4',
    },
    disabled: false,
    itemKey: null,
    style: {},
    children: null,
    useNativeAndroidPickerStyle: true,
    fixAndroidTouchableBug: false,
    doneText: 'Done',
    onDonePress: null,
    onUpArrow: null,
    onDownArrow: null,
    onOpen: null,
    onClose: null,
    modalProps: {},
    textInputProps: {},
    pickerProps: {},
    touchableDoneProps: {},
    touchableWrapperProps: {},
    Icon: null,
    InputAccessoryView: null,
    darkTheme: false,
  };

  static handlePlaceholder({ placeholder }) {
    if (isEqual(placeholder, {})) {
      return [];
    }
    return [placeholder];
  }

  static getSelectedItem({ items, key, value }) {
    let idx = items.findIndex((item) => {
      if (item.key && key) {
        return isEqual(item.key, key);
      }
      if (isObject(item.value) || isObject(value)) {
        return isEqual(item.value, value);
      }
      // convert to string to make sure types match
      return isEqual(String(item.value), String(value));
    });
    if (idx === -1) {
      idx = 0;
    }
    return {
      selectedItem: items[idx] || {},
      idx,
    };
  }

  constructor(props) {
    super(props);

    const items = RNPickerSelect.handlePlaceholder({
      placeholder: props.placeholder,
    }).concat(props.items);

    const { selectedItem } = RNPickerSelect.getSelectedItem({
      items,
      key: props.itemKey,
      value: props.value,
    });

    this.state = {
      items,
      selectedItem,
      showPicker: false,
      animationType: undefined,
      orientation: 'portrait',
      doneDepressed: false,
    };

    this.onUpArrow = this.onUpArrow.bind(this);
    this.onDownArrow = this.onDownArrow.bind(this);
    this.onValueChange = this.onValueChange.bind(this);
    this.onOrientationChange = this.onOrientationChange.bind(this);
    this.setInputRef = this.setInputRef.bind(this);
    this.togglePicker = this.togglePicker.bind(this);
    this.renderInputAccessoryView = this.renderInputAccessoryView.bind(this);
  }

  componentDidUpdate = (prevProps, prevState) => {
    // update items if items or placeholder prop changes
    const items = RNPickerSelect.handlePlaceholder({
      placeholder: this.props.placeholder,
    }).concat(this.props.items);
    const itemsChanged = !isEqual(prevState.items, items);

    // update selectedItem if value prop is defined and differs from currently selected item
    const { selectedItem, idx } = RNPickerSelect.getSelectedItem({
      items,
      key: this.props.itemKey,
      value: this.props.value,
    });
    const selectedItemChanged =
      !isEqual(this.props.value, undefined) && !isEqual(prevState.selectedItem, selectedItem);

    if (itemsChanged || selectedItemChanged) {
      this.props.onValueChange(selectedItem.value, idx);

      this.setState({
        ...(itemsChanged ? { items } : {}),
        ...(selectedItemChanged ? { selectedItem } : {}),
      });
    }
  };

  onUpArrow() {
    const { onUpArrow } = this.props;

    this.togglePicker(false, onUpArrow);
  }

  onDownArrow() {
    const { onDownArrow } = this.props;

    this.togglePicker(false, onDownArrow);
  }

  onValueChange(value, index) {
    const { onValueChange } = this.props;

    onValueChange(value, index);

    this.setState((prevState) => {
      return {
        selectedItem: prevState.items[index],
      };
    });
  }

  onOrientationChange({ nativeEvent }) {
    this.setState({
      orientation: nativeEvent.orientation,
    });
  }

  setInputRef(ref) {
    this.inputRef = ref;
  }

  getPlaceholderStyle() {
    const { placeholder, style } = this.props;
    const { selectedItem } = this.state;

    if (!isEqual(placeholder, {}) && selectedItem.label === placeholder.label) {
      return {
        ...defaultStyles.placeholder,
        ...style.placeholder,
      };
    }
    return {};
  }

  isDarkTheme() {
    const { darkTheme } = this.props;

    return Platform.OS === 'ios' && darkTheme;
  }

  triggerOpenCloseCallbacks(donePressed) {
    const { onOpen, onClose } = this.props;
    const { showPicker } = this.state;

    if (!showPicker && onOpen) {
      onOpen();
    }

    if (showPicker && onClose) {
      onClose(donePressed);
    }
  }

  togglePicker(animate = false, postToggleCallback, donePressed = false) {
    const { modalProps, disabled } = this.props;
    const { showPicker } = this.state;

    if (disabled) {
      return;
    }

    if (!showPicker) {
      Keyboard.dismiss();
    }

    const animationType =
      modalProps && modalProps.animationType ? modalProps.animationType : 'slide';

    this.triggerOpenCloseCallbacks(donePressed);

    this.setState(
      (prevState) => {
        return {
          animationType: animate ? animationType : undefined,
          showPicker: !prevState.showPicker,
        };
      },
      () => {
        if (postToggleCallback) {
          postToggleCallback();
        }
      }
    );
  }

  renderPickerItems() {
    const { items } = this.state;
    const defaultItemColor = this.isDarkTheme() ? '#fff' : undefined;

    return items.map((item) => {
      return (
        <Picker.Item
          label={item.label}
          value={item.value}
          key={item.key || item.label}
          color={item.color || defaultItemColor}
          testID={item.testID}
        />
      );
    });
  }

  renderInputAccessoryView() {
    const {
      InputAccessoryView,
      doneText,
      onUpArrow,
      onDownArrow,
      onDonePress,
      style,
      touchableDoneProps,
    } = this.props;

    const { doneDepressed } = this.state;

    if (InputAccessoryView) {
      return <InputAccessoryView testID="custom_input_accessory_view" />;
    }

    return (
      <View
        style={[
          defaultStyles.modalViewMiddle,
          this.isDarkTheme() ? defaultStyles.modalViewMiddleDark : {},
          this.isDarkTheme() ? style.modalViewMiddleDark : style.modalViewMiddle,
        ]}
        testID="input_accessory_view"
      >
        <View style={[defaultStyles.chevronContainer, style.chevronContainer]}>
          <TouchableOpacity
            activeOpacity={onUpArrow ? 0.5 : 1}
            onPress={onUpArrow ? this.onUpArrow : null}
          >
            <View
              style={[
                defaultStyles.chevron,
                this.isDarkTheme() ? defaultStyles.chevronDark : {},
                this.isDarkTheme() ? style.chevronDark : style.chevron,
                defaultStyles.chevronUp,
                style.chevronUp,
                onUpArrow ? [defaultStyles.chevronActive, style.chevronActive] : {},
              ]}
            />
          </TouchableOpacity>
          <TouchableOpacity
            activeOpacity={onDownArrow ? 0.5 : 1}
            onPress={onDownArrow ? this.onDownArrow : null}
          >
            <View
              style={[
                defaultStyles.chevron,
                this.isDarkTheme() ? defaultStyles.chevronDark : {},
                this.isDarkTheme() ? style.chevronDark : style.chevron,
                defaultStyles.chevronDown,
                style.chevronDown,
                onDownArrow ? [defaultStyles.chevronActive, style.chevronActive] : {},
              ]}
            />
          </TouchableOpacity>
        </View>
        <TouchableOpacity
          testID="done_button"
          onPress={() => {
            this.togglePicker(true, onDonePress, true);
          }}
          onPressIn={() => {
            this.setState({ doneDepressed: true });
          }}
          onPressOut={() => {
            this.setState({ doneDepressed: false });
          }}
          hitSlop={{
            top: 4,
            right: 4,
            bottom: 4,
            left: 4,
          }}
          {...touchableDoneProps}
        >
          <View testID="needed_for_touchable">
            <Text
              testID="done_text"
              allowFontScaling={false}
              style={[
                defaultStyles.done,
                this.isDarkTheme() ? defaultStyles.doneDark : {},
                this.isDarkTheme() ? style.doneDark : style.done,
                doneDepressed ? [defaultStyles.doneDepressed, style.doneDepressed] : {},
              ]}
            >
              {doneText}
            </Text>
          </View>
        </TouchableOpacity>
      </View>
    );
  }

  renderIcon() {
    const { style, Icon } = this.props;

    if (!Icon) {
      return null;
    }

    return (
      <View testID="icon_container" style={[defaultStyles.iconContainer, style.iconContainer]}>
        <Icon testID="icon" />
      </View>
    );
  }

  renderTextInputOrChildren() {
    const { children, style, textInputProps } = this.props;
    const { selectedItem } = this.state;

    const containerStyle =
      Platform.OS === 'ios' ? style.inputIOSContainer : style.inputAndroidContainer;

    if (children) {
      return (
        <View pointerEvents="box-only" style={containerStyle}>
          {children}
        </View>
      );
    }

    return (
      <View pointerEvents="box-only" style={containerStyle}>
        <TextInput
          testID="text_input"
          style={[
            Platform.OS === 'ios' ? style.inputIOS : style.inputAndroid,
            this.getPlaceholderStyle(),
          ]}
          value={selectedItem.inputLabel ? selectedItem.inputLabel : selectedItem.label}
          ref={this.setInputRef}
          editable={false}
          {...textInputProps}
        />
        {this.renderIcon()}
      </View>
    );
  }

  renderIOS() {
    const { style, modalProps, pickerProps, touchableWrapperProps } = this.props;
    const { animationType, orientation, selectedItem, showPicker } = this.state;

    return (
      <View style={[defaultStyles.viewContainer, style.viewContainer]}>
        <TouchableOpacity
          testID="ios_touchable_wrapper"
          onPress={() => {
            this.togglePicker(true);
          }}
          activeOpacity={1}
          {...touchableWrapperProps}
        >
          {this.renderTextInputOrChildren()}
        </TouchableOpacity>
        <Modal
          testID="ios_modal"
          visible={showPicker}
          transparent
          animationType={animationType}
          supportedOrientations={['portrait', 'landscape']}
          onOrientationChange={this.onOrientationChange}
          {...modalProps}
        >
          <TouchableOpacity
            style={[defaultStyles.modalViewTop, style.modalViewTop]}
            testID="ios_modal_top"
            onPress={() => {
              this.togglePicker(true);
            }}
          />
          {this.renderInputAccessoryView()}
          <View
            style={[
              defaultStyles.modalViewBottom,
              this.isDarkTheme() ? defaultStyles.modalViewBottomDark : {},
              { height: orientation === 'portrait' ? 215 : 162 },
              this.isDarkTheme() ? style.modalViewBottomDark : style.modalViewBottom,
            ]}
          >
            <Picker
              testID="ios_picker"
              onValueChange={this.onValueChange}
              selectedValue={selectedItem.value}
              {...pickerProps}
            >
              {this.renderPickerItems()}
            </Picker>
          </View>
        </Modal>
      </View>
    );
  }

  renderAndroidHeadless() {
    const {
      disabled,
      Icon,
      style,
      pickerProps,
      onOpen,
      touchableWrapperProps,
      fixAndroidTouchableBug,
    } = this.props;
    const { selectedItem } = this.state;

    const Component = fixAndroidTouchableBug ? View : TouchableOpacity;
    return (
      <Component
        testID="android_touchable_wrapper"
        onPress={onOpen}
        activeOpacity={1}
        {...touchableWrapperProps}
      >
        <View style={style.headlessAndroidContainer}>
          {this.renderTextInputOrChildren()}
          <Picker
            style={[
              Icon ? { backgroundColor: 'transparent' } : {}, // to hide native icon
              defaultStyles.headlessAndroidPicker,
              style.headlessAndroidPicker,
            ]}
            testID="android_picker_headless"
            enabled={!disabled}
            onValueChange={this.onValueChange}
            selectedValue={selectedItem.value}
            {...pickerProps}
          >
            {this.renderPickerItems()}
          </Picker>
        </View>
      </Component>
    );
  }

  renderAndroidNativePickerStyle() {
    const { disabled, Icon, style, pickerProps } = this.props;
    const { selectedItem } = this.state;

    return (
      <View style={[defaultStyles.viewContainer, style.viewContainer]}>
        <Picker
          style={[
            Icon ? { backgroundColor: 'transparent' } : {}, // to hide native icon
            style.inputAndroid,
            this.getPlaceholderStyle(),
          ]}
          testID="android_picker"
          enabled={!disabled}
          onValueChange={this.onValueChange}
          selectedValue={selectedItem.value}
          {...pickerProps}
        >
          {this.renderPickerItems()}
        </Picker>
        {this.renderIcon()}
      </View>
    );
  }

  renderWeb() {
    const { disabled, style, pickerProps } = this.props;
    const { selectedItem } = this.state;

    return (
      <View style={[defaultStyles.viewContainer, style.viewContainer]}>
        <Picker
          style={[style.inputWeb]}
          testID="web_picker"
          enabled={!disabled}
          onValueChange={this.onValueChange}
          selectedValue={selectedItem.value}
          {...pickerProps}
        >
          {this.renderPickerItems()}
        </Picker>
        {this.renderIcon()}
      </View>
    );
  }

  render() {
    const { children, useNativeAndroidPickerStyle } = this.props;

    if (Platform.OS === 'ios') {
      return this.renderIOS();
    }

    if (Platform.OS === 'web') {
      return this.renderWeb();
    }

    if (children || !useNativeAndroidPickerStyle) {
      return this.renderAndroidHeadless();
    }

    return this.renderAndroidNativePickerStyle();
  }
}

export { defaultStyles };