Repository: react-materialize/react-materialize
Pull request #654 (2018-09-30 15:47:49)

3.0.0 select.js
Extracts `<Select />` from old `<Input type='select' />`


Patch:
diff --git a/src/Select.js b/src/Select.js
new file mode 100644
index 0000000..c5c0122
--- /dev/null
+++ b/src/Select.js
@@ -0,0 +1,196 @@
+import React, { Component } from 'react';
+import PropTypes from 'prop-types';
+import cx from 'classnames';
+import idgen from './idgen';
+import constants from './constants';
+
+class Select extends Component {
+  constructor(props) {
+    super(props);
+
+    this.id = props.id || idgen();
+    this.handleChange = this.handleChange.bind(this);
+    this.state = { value: props.value };
+  }
+
+  handleChange(event) {
+    const { onChange } = this.props;
+    let value = event.target.value;
+
+    if (this.props.multiple) {
+      value = this.instance.getSelectedValues();
+    }
+
+    if (onChange) onChange(event);
+
+    this.setState({ value });
+  }
+
+  componentDidMount() {
+    if (typeof M !== 'undefined') {
+      this.instance = M.FormSelect.init(this._selectRef);
+    }
+  }
+
+  render() {
+    const {
+      s,
+      m,
+      l,
+      disabled,
+      noLayout,
+      browserDefault,
+      icon,
+      label,
+      selectClassName,
+      success,
+      error,
+      validate,
+      children,
+      multiple,
+      value,
+      type
+    } = this.props;
+
+    const sizes = { s, m, l };
+
+    let responsiveClasses;
+    if (!noLayout) {
+      responsiveClasses = { col: true };
+      constants.SIZES.forEach(size => {
+        responsiveClasses[size + sizes[size]] = sizes[size];
+      });
+    }
+
+    const wrapperClasses = cx('input-field', responsiveClasses);
+
+    const selectProps = {
+      type: 'select',
+      id: this.id,
+      value: this.state.value,
+      disabled,
+      multiple
+    };
+
+    const renderLabel = () =>
+      label && (
+        <label
+          data-success={success}
+          data-error={error}
+          htmlFor={selectProps.id}
+        >
+          {label}
+        </label>
+      );
+
+    const renderIcon = () =>
+      icon && <i className="material-icons prefix">{icon}</i>;
+
+    const renderOption = child =>
+      React.cloneElement(child, { key: child.props.value });
+
+    const renderOptions = () => React.Children.map(children, renderOption);
+
+    return (
+      <div className={wrapperClasses}>
+        {renderIcon()}
+        <select
+          value={this.state.value}
+          ref={el => {
+            this._selectRef = el;
+          }}
+          onChange={this.handleChange}
+          className={cx(
+            {
+              validate,
+              multiple,
+              ['browser-default']: browserDefault
+            },
+            selectClassName
+          )}
+          {...selectProps}
+        >
+          {renderOptions()}
+        </select>
+        {renderLabel()}
+      </div>
+    );
+  }
+}
+
+Select.propTypes = {
+  /*
+   * Use browser default styles
+   */
+  browserDefault: PropTypes.bool,
+  /*
+   * Strip away all layout classes such as col and sX
+   */
+  noLayout: PropTypes.bool,
+  /*
+   * Responsive size for Small
+   */
+  s: PropTypes.number,
+  /*
+   * Responsive size for Medium
+   */
+  m: PropTypes.number,
+  /*
+   * Responsive size for Large
+   */
+  l: PropTypes.number,
+  /*
+   * disabled input
+   */
+  disabled: PropTypes.bool,
+  /*
+   * override id
+   * @default idgen()
+   */
+  id: PropTypes.string,
+  /*
+   * prefix icon
+   */
+  icon: PropTypes.string,
+  /*
+   * label text
+   */
+  label: PropTypes.string,
+  /*
+   * Input initial value
+   */
+  value: PropTypes.string,
+  /*
+   * Add validate class to input
+   */
+  validate: PropTypes.bool,
+  /*
+   * Custom success message
+   */
+  success: PropTypes.string,
+  /*
+   * Custom error message
+   */
+  error: PropTypes.string,
+  /*
+   * Additional classes for input
+   */
+  selectClassName: PropTypes.string,
+  /*
+   * override type="text"
+   */
+  type: PropTypes.string,
+  /*
+   * onChange callback
+   */
+  onChange: PropTypes.func,
+  /*
+   * Render a multiple dropdown,
+   * use instance.getSelectedValues()
+   * to get array of selected values
+   */
+  multiple: PropTypes.bool,
+  children: PropTypes.any
+};
+
+export default Select;
diff --git a/src/index.js b/src/index.js
index 39bc970..721685e 100644
--- a/src/index.js
+++ b/src/index.js
@@ -33,6 +33,7 @@ import SearchForm from './SearchForm';
 import Section from './Section';
 import SideNav from './SideNav';
 import SideNavItem from './SideNavItem';
+import Select from './Select';
 import Slide from './Slide';
 import Slider from './Slider';
 import Tab from './Tab';
@@ -78,6 +79,7 @@ export {
   Section,
   SideNav,
   SideNavItem,
+  Select,
   Slide,
   Slider,
   Tab,
diff --git a/stories/Select.stories.js b/stories/Select.stories.js
new file mode 100644
index 0000000..79eddec
--- /dev/null
+++ b/stories/Select.stories.js
@@ -0,0 +1,53 @@
+import React from 'react';
+import { storiesOf } from '@storybook/react';
+import Select from '../src/Select';
+
+const stories = storiesOf('components/Select', module);
+
+stories.addParameters({
+  info: {
+    text: `Select allows user input through specified options.`
+  }
+});
+
+stories.add('Default', () => (
+  <Select>
+    <option value="" disabled selected>Choose your option</option>
+    <option value="1">Option 1</option>
+    <option value="2">Option 2</option>
+    <option value="3">Option 3</option>
+  </Select>
+));
+
+stories.add('Multiple', () => (
+  <Select multiple>
+    <option value="" disabled selected>Choose your option</option>
+    <option value="1">Option 1</option>
+    <option value="2">Option 2</option>
+    <option value="3">Option 3</option>
+  </Select>
+));
+
+stories.add('Label', () => (
+  <Select label="Choose your option">
+    <option value="1">Option 1</option>
+    <option value="2" selected>Option 2</option>
+    <option value="3">Option 3</option>
+  </Select>
+));
+
+stories.add('Disabled', () => (
+  <Select disabled>
+    <option value="1">Option 1</option>
+    <option value="2" selected>Option 2</option>
+    <option value="3">Option 3</option>
+  </Select>
+));
+
+stories.add('Browser default', () => (
+  <Select browserDefault>
+    <option value="1">Option 1</option>
+    <option value="2" selected>Option 2</option>
+    <option value="3">Option 3</option>
+  </Select>
+));


Test patch:
diff --git a/test/Select.spec.js b/test/Select.spec.js
new file mode 100644
index 0000000..a169c26
--- /dev/null
+++ b/test/Select.spec.js
@@ -0,0 +1,98 @@
+import React from 'react';
+import { shallow, mount } from 'enzyme';
+import Select from '../src/Select';
+import mocker from './helper/new-mocker';
+
+describe('<Select />', () => {
+  let wrapper;
+
+  test('handles layout classes', () => {
+    wrapper = shallow(<Select s={4} m={6} />);
+
+    expect(wrapper.hasClass('input-field')).toBeTruthy();
+    expect(wrapper.hasClass('col')).toBeTruthy();
+    expect(wrapper.hasClass('s4')).toBeTruthy();
+    expect(wrapper.hasClass('m6')).toBeTruthy();
+  });
+
+  test('handles removing layout classes', () => {
+    wrapper = shallow(<Select s={4} m={6} noLayout />);
+
+    expect(wrapper.hasClass('input-field')).toBeTruthy();
+    expect(wrapper.hasClass('col')).toBeFalsy();
+    expect(wrapper.hasClass('s4')).toBeFalsy();
+    expect(wrapper.hasClass('m6')).toBeFalsy();
+  });
+
+  test('renders browserDefault', () => {
+    wrapper = shallow(<Select browserDefault />);
+    expect(wrapper.find('select').hasClass('browser-default')).toBeTruthy();
+  });
+
+  test('renders label', () => {
+    wrapper = shallow(<Select label="Name" />);
+    expect(wrapper.find('label').text()).toEqual('Name');
+  });
+
+  test('handles validate', () => {
+    wrapper = shallow(<Select validate />);
+    expect(wrapper.find('select.validate')).toHaveLength(1);
+  });
+
+  test('handles selectClassName', () => {
+    wrapper = shallow(<Select validate selectClassName={'hello red'} />);
+    expect(wrapper.find('select.hello.red.validate')).toHaveLength(1);
+  });
+
+  test('calls onChange if provided', () => {
+    wrapper = shallow(<Select />);
+    wrapper.setState({ value: 'hello' });
+    expect(wrapper.find('select').prop('value')).toEqual('hello');
+  });
+
+  test('with icon', () => {
+    wrapper = shallow(<Select icon="cloud" />);
+    expect(wrapper.find('i.material-icons.prefix')).toHaveLength(1);
+  });
+
+  test('renders options', () => {
+    wrapper = shallow(
+      <Select>
+        <option value={1}>1</option>
+        <option value={2}>2</option>
+      </Select>
+    );
+
+    expect(wrapper.find('option')).toHaveLength(2);
+  });
+
+  test('renders multiple', () => {
+    wrapper = shallow(<Select multiple />);
+    expect(wrapper.find('select.multiple')).toHaveLength(1);
+  });
+
+  describe('initialises', () => {
+    const selectInitMock = jest.fn();
+    const selectInstanceDestroyMock = jest.fn();
+    const selectMock = {
+      init: (el, options) => {
+        selectInitMock(options);
+        return {
+          destroy: selectInstanceDestroyMock
+        };
+      }
+    };
+    const restore = mocker('FormSelect', selectMock);
+
+    beforeEach(() => {
+      selectInitMock.mockClear();
+      selectInstanceDestroyMock.mockClear();
+    });
+
+    test('calls FormSelect', () => {
+      mount(<Select />);
+
+      expect(selectInitMock).toHaveBeenCalledTimes(1);
+    });
+  });
+});