Repository: react-materialize/react-materialize
Pull request #1075 (2020-03-28 12:50:24)

Modal hooks
Continuation from #959 


Patch:
diff --git a/.storybook/config.js b/.storybook/config.js
index edc97c4..4aad52e 100644
--- a/.storybook/config.js
+++ b/.storybook/config.js
@@ -39,7 +39,7 @@ addParameters({
      * where to show the addon panel
      * @type {String}
      */
-    panelPosition: 'bottom',
+    panelPosition: 'right',
     /**
      * sorts stories
      * @type {Boolean}
diff --git a/src/Autocomplete.js b/src/Autocomplete.js
index f8c824b..2357a66 100644
--- a/src/Autocomplete.js
+++ b/src/Autocomplete.js
@@ -1,52 +1,110 @@
-import React, { cloneElement, useEffect, useRef } from 'react';
+/* eslint no-unused-vars: ["error", { "ignoreRestSiblings": true }] */
+import React, { Component } from 'react';
 import PropTypes from 'prop-types';
 import cx from 'classnames';
 import constants from './constants';
 import idgen from './idgen';
 
-const Autocomplete = ({
-  className,
-  title,
-  icon,
-  s,
-  m,
-  l,
-  xl,
-  id,
-  options,
-  ...rest
-}) => {
-  const autocompleteRef = useRef(null);
-  const sizes = { s, m, l, xl };
-
-  let classes = { col: true };
-
-  constants.SIZES.forEach(size => {
-    classes[size + sizes[size]] = sizes[size];
-  });
-
-  useEffect(() => {
-    const instance = M.Autocomplete.init(autocompleteRef.current, options);
-
-    return () => {
-      instance && instance.destroy();
+class Autocomplete extends Component {
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      value: props.value || '',
+      itemSelected: false
     };
-  }, [options]);
-
-  return (
-    <div className={cx('input-field', className, classes)}>
-      {icon && cloneElement(icon, { className: 'prefix' })}
-      <input
-        className="autocomplete"
-        type="text"
-        ref={autocompleteRef}
-        id={id}
-        {...rest}
-      />
-      <label htmlFor={id}>{title}</label>
-    </div>
-  );
-};
+
+    this.renderIcon = this.renderIcon.bind(this);
+    this._onChange = this._onChange.bind(this);
+  }
+
+  componentDidMount() {
+    if (typeof M !== 'undefined') {
+      const { options } = this.props;
+      this.instance = M.Autocomplete.init(this._autocomplete, options);
+    }
+  }
+
+  componentWillUnmount() {
+    if (this.instance) {
+      this.instance.destroy();
+    }
+  }
+
+  renderIcon(icon) {
+    return React.cloneElement(icon, { className: 'prefix' });
+  }
+
+  _onChange(e) {
+    const { onChange } = this.props;
+    const value = e.target.value;
+
+    onChange && onChange(e, value);
+
+    this.setState({ value, itemSelected: false });
+  }
+
+  _onAutocomplete(value, e) {
+    const { onChange, options } = this.props;
+    const { onAutocomplete } = options;
+
+    onAutocomplete && onAutocomplete(value);
+
+    onChange && onChange(e, value);
+
+    this.setState({ value, itemSelected: true });
+  }
+
+  render() {
+    const {
+      id,
+      className,
+      title,
+      icon,
+      s,
+      m,
+      l,
+      xl,
+      offset,
+      placeholder,
+      // these are mentioned here only to prevent from getting into ...props
+      value,
+      onChange,
+      options,
+      ...props
+    } = this.props;
+
+    const sizes = { s, m, l, xl };
+    let classes = {
+      col: true
+    };
+    constants.SIZES.forEach(size => {
+      classes[size + sizes[size]] = sizes[size];
+    });
+
+    return (
+      <div
+        offset={offset}
+        className={cx('input-field', className, classes)}
+        {...props}
+      >
+        {icon && this.renderIcon(icon)}
+        <input
+          placeholder={placeholder}
+          className="autocomplete"
+          id={id}
+          onChange={this._onChange}
+          type="text"
+          value={this.state.value}
+          ref={input => {
+            this._autocomplete = input;
+          }}
+        />
+        <label htmlFor={id}>{title}</label>
+      </div>
+    );
+  }
+}
 
 Autocomplete.propTypes = {
   /**
@@ -68,6 +126,7 @@ Autocomplete.propTypes = {
   m: PropTypes.number,
   l: PropTypes.number,
   xl: PropTypes.number,
+  offset: PropTypes.string,
   /**
    * Placeholder for input element
    * */
diff --git a/src/Modal.js b/src/Modal.js
index 19faa3e..b799118 100644
--- a/src/Modal.js
+++ b/src/Modal.js
@@ -1,67 +1,69 @@
-import React, { Component, Fragment } from 'react';
-import ReactDOM from 'react-dom';
+import React, {
+  Fragment,
+  Children,
+  cloneElement,
+  useRef,
+  useEffect
+} from 'react';
+import { createPortal } from 'react-dom';
 import PropTypes from 'prop-types';
 import cx from 'classnames';
 
 import idgen from './idgen';
 import Button from './Button';
 
-class Modal extends Component {
-  constructor(props) {
-    super(props);
-    this.showModal = this.showModal.bind(this);
-    this.createRoot();
-  }
-
-  createRoot() {
-    this.modalRoot = document.createElement('div');
-    document.body.appendChild(this.modalRoot);
-  }
-
-  componentDidMount() {
-    if (typeof M !== 'undefined') {
-      const { options, open } = this.props;
+const MODAL_CONTAINER = document.body;
+
+const Modal = ({
+  actions,
+  bottomSheet,
+  children,
+  fixedFooter,
+  header,
+  className,
+  trigger,
+  options,
+  open,
+  ...props
+}) => {
+  const _modal = useRef(null);
+  const _modalRoot = useRef(null);
+  const _modalInstance = useRef(null);
+  const currentRoot = _modalRoot.current;
+
+  useEffect(() => {
+    _modalInstance.current = M.Modal.init(_modal.current, options);
+
+    return () => {
+      // eslint-disable-next-line react-hooks/exhaustive-deps
+      MODAL_CONTAINER.removeChild(_modalRoot.current);
+      _modalInstance.current && _modalInstance.current.destroy();
+    };
+  }, [options, currentRoot]);
+
+  useEffect(() => {
+    if (open) {
+      showModal();
+    } else {
+      hideModal();
+    }
+  }, [open]);
 
-      this.instance = M.Modal.init(this._modal, options);
+  const showModal = e => {
+    e && e.preventDefault();
 
-      if (open) this.showModal();
-    }
-  }
+    _modalInstance.current && _modalInstance.current.open();
+  };
 
-  componentWillUnmount() {
-    document.body.removeChild(this.modalRoot);
+  const hideModal = e => {
+    e && e.preventDefault();
 
-    if (this.instance) {
-      this.hideModal();
-      this.instance.destroy();
-    }
-  }
-
-  componentWillReceiveProps(nextProps) {
-    // if the modal is not open yet
-    if (!this.props.open && nextProps.open) {
-      this.showModal();
-      // open could be undefined
-    } else if (nextProps.open === false) {
-      this.hideModal();
-    }
-  }
-
-  renderModalPortal() {
-    const {
-      id,
-      actions,
-      bottomSheet,
-      children,
-      fixedFooter,
-      header,
-      className,
-      ...other
-    } = this.props;
-
-    delete other.options;
-    delete other.trigger;
+    _modalInstance.current && _modalInstance.current.close();
+  };
 
+  const renderModalPortal = () => {
+    _modalRoot.current = document.createElement('div');
+    MODAL_CONTAINER.appendChild(_modalRoot.current);
     const classes = cx(
       'modal',
       {
@@ -72,50 +74,27 @@ class Modal extends Component {
     );
 
     return (
-      this.modalRoot &&
-      ReactDOM.createPortal(
-        <div
-          id={id}
-          className={classes}
-          ref={div => {
-            this._modal = div;
-          }}
-          {...other}
-        >
+      _modalRoot.current &&
+      createPortal(
+        <div className={classes} ref={_modal} {...props}>
           <div className="modal-content">
             <h4>{header}</h4>
             {children}
           </div>
-          <div className="modal-footer">{React.Children.toArray(actions)}</div>
+          <div className="modal-footer">{Children.toArray(actions)}</div>
         </div>,
-        this.modalRoot
+        _modalRoot.current
       )
     );
-  }
-
-  showModal(e) {
-    e && e.preventDefault();
-
-    this.instance && this.instance.open();
-  }
-
-  hideModal(e) {
-    e && e.preventDefault();
-
-    this.instance && this.instance.close();
-  }
-
-  render() {
-    const { trigger } = this.props;
-
-    return (
-      <Fragment>
-        {trigger && React.cloneElement(trigger, { onClick: this.showModal })}
-        {this.renderModalPortal()}
-      </Fragment>
-    );
-  }
-}
+  };
+
+  return (
+    <Fragment>
+      {trigger && cloneElement(trigger, { onClick: showModal })}
+      {renderModalPortal()}
+    </Fragment>
+  );
+};
 
 Modal.propTypes = {
   /**
@@ -174,6 +153,7 @@ Modal.propTypes = {
   className: PropTypes.string,
   /**
    * Modal is opened on mount
+   * @default false
    */
   open: PropTypes.bool,
   /**
@@ -211,6 +191,7 @@ Modal.propTypes = {
 
 Modal.defaultProps = {
   id: `Modal-${idgen()}`,
+  open: false,
   options: {
     opacity: 0.5,
     inDuration: 250,
diff --git a/src/Select.js b/src/Select.js
index f5fb13a..e1c132e 100644
--- a/src/Select.js
+++ b/src/Select.js
@@ -1,106 +1,137 @@
-import React, {
-  Children,
-  cloneElement,
-  useRef,
-  useEffect,
-  useState
-} from 'react';
+import React, { Component, Children, cloneElement } from 'react';
 import PropTypes from 'prop-types';
 import cx from 'classnames';
 import idgen from './idgen';
 import constants from './constants';
 
-const Select = ({
-  id,
-  s,
-  m,
-  l,
-  xl,
-  noLayout,
-  browserDefault,
-  icon,
-  label,
-  className,
-  success,
-  error,
-  validate,
-  children,
-  multiple,
-  options,
-  value,
-  onChange,
-  ...props
-}) => {
-  const [selectedValue, setSelectedValue] = useState(value);
-  const _selectRef = useRef(null);
-  const _formSelectInstance = useRef(null);
-
-  useEffect(() => {
-    _formSelectInstance.current = M.FormSelect.init(
-      _selectRef.current,
-      options
-    );
+class Select extends Component {
+  constructor(props) {
+    super(props);
 
-    return () => {
-      _formSelectInstance.current && _formSelectInstance.current.destroy();
-    };
-  }, [options]);
+    this.handleChange = this.handleChange.bind(this);
+    this.state = { value: props.value };
+  }
+
+  handleChange(event) {
+    const { onChange, multiple } = this.props;
+    let value = event.target.value;
 
-  useEffect(() => {
-    setSelectedValue(value);
-  }, [value]);
+    if (multiple) {
+      value = this.instance.getSelectedValues();
+    }
 
-  const sizes = { s, m, l, xl };
-  let responsiveClasses = {};
+    if (onChange) onChange(event);
 
-  if (!noLayout) {
-    responsiveClasses = { col: true };
-    constants.SIZES.forEach(size => {
-      responsiveClasses[size + sizes[size]] = sizes[size];
-    });
+    this.setState({ value });
   }
 
-  const renderOption = child => cloneElement(child, { key: child.props.value });
+  componentDidMount() {
+    const { options } = this.props;
 
-  const handleChange = e => {
-    let value = e.target.value;
+    if (typeof M !== 'undefined') {
+      this.instance = M.FormSelect.init(this._selectRef, options);
+    }
+  }
 
-    if (multiple) {
-      value = _formSelectInstance.current.getSelectedValues();
+  componentDidUpdate() {
+    const { options } = this.props;
+
+    if (this.instance) {
+      this.instance.destroy();
     }
 
-    onChange && onChange(e);
-
-    setSelectedValue(value);
-  };
-
-  return (
-    <div className={cx('input-field', responsiveClasses)}>
-      {Boolean(icon) && cloneElement(icon, { className: 'prefix' })}
-      <select
-        {...props}
-        type="select"
-        id={id}
-        value={selectedValue}
-        ref={_selectRef}
-        onChange={handleChange}
-        className={cx(className, {
-          validate,
-          multiple,
-          ['browser-default']: browserDefault
-        })}
-        multiple={multiple}
-      >
-        {Children.map(children, renderOption)}
-      </select>
-      {Boolean(label) && (
-        <label data-success={success} data-error={error} htmlFor={id}>
+    this.instance = M.FormSelect.init(this._selectRef, options);
+  }
+
+  componentWillUnmount() {
+    if (this.instance) {
+      this.instance.destroy();
+    }
+  }
+
+  render() {
+    const {
+      s,
+      m,
+      l,
+      xl,
+      noLayout,
+      browserDefault,
+      icon,
+      label,
+      selectClassName,
+      success,
+      error,
+      validate,
+      children,
+      multiple,
+      ...other
+    } = this.props;
+
+    const sizes = { s, m, l, xl };
+
+    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',
+      value: this.state.value,
+      multiple,
+      ...other
+    };
+
+    const renderLabel = () =>
+      label && (
+        <label
+          data-success={success}
+          data-error={error}
+          htmlFor={selectProps.id}
+        >
           {label}
         </label>
-      )}
-    </div>
-  );
-};
+      );
+
+    const renderIcon = () =>
+      icon && React.cloneElement(icon, { className: 'prefix' });
+
+    const renderOption = child =>
+      cloneElement(child, { key: child.props.value });
+
+    const renderOptions = () => 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 = {
   /*
@@ -163,7 +194,7 @@ Select.propTypes = {
   /*
    * Additional classes for input
    */
-  className: PropTypes.string,
+  selectClassName: PropTypes.string,
   /*
    * override type="text"
    */
@@ -209,7 +240,6 @@ Select.propTypes = {
 
 Select.defaultProps = {
   id: `Select-${idgen()}`,
-  multiple: false,
   options: {
     classes: '',
     dropdownOptions: {
diff --git a/stories/Autocomplete.stories.js b/stories/Autocomplete.stories.js
index b37bfe1..f7ff10a 100644
--- a/stories/Autocomplete.stories.js
+++ b/stories/Autocomplete.stories.js
@@ -2,7 +2,7 @@ import React from 'react';
 import { storiesOf } from '@storybook/react';
 import Autocomplete from '../src/Autocomplete';
 import Icon from '../src/Icon';
-import { text, withKnobs, object } from '@storybook/addon-knobs';
+import { text, withKnobs } from '@storybook/addon-knobs';
 
 const stories = storiesOf('Javascript|Autocomplete', module);
 stories.addDecorator(withKnobs);
@@ -16,11 +16,11 @@ stories.add(
   () => (
     <Autocomplete
       options={{
-        data: object('Data', {
+        data: {
           ['Gus Fring']: null,
           ['Saul Goodman']: null,
           ['Tuco Salamanca']: 'https://placehold.it/250x250'
-        })
+        }
       }}
       placeholder="Insert here"
     />
@@ -33,11 +33,11 @@ stories.add(
   () => (
     <Autocomplete
       options={{
-        data: object('Data', {
+        data: {
           ['Gus Fring']: null,
           ['Saul Goodman']: null,
           ['Tuco Salamanca']: 'https://placehold.it/250x250'
-        })
+        }
       }}
       placeholder="Insert here"
       icon={<Icon>{text('icon', 'textsms')}</Icon>}
diff --git a/stories/Select.stories.js b/stories/Select.stories.js
index dbd301e..f7af59e 100644
--- a/stories/Select.stories.js
+++ b/stories/Select.stories.js
@@ -3,7 +3,6 @@ import { storiesOf } from '@storybook/react';
 import { action } from '@storybook/addon-actions';
 
 import Select from '../src/Select';
-import Icon from '../src/Icon';
 
 const stories = storiesOf('Components|Select', module);
 
@@ -25,7 +24,7 @@ stories.add('Default', () => (
 ));
 
 stories.add('with Icon', () => (
-  <Select value="" icon={<Icon>cloud</Icon>}>
+  <Select value="" icon="cloud">
     <option value="" disabled>
       Choose your option
     </option>


Test patch:
diff --git a/test/Autocomplete.spec.js b/test/Autocomplete.spec.js
index 4c844fa..22195f4 100644
--- a/test/Autocomplete.spec.js
+++ b/test/Autocomplete.spec.js
@@ -4,7 +4,6 @@ import Autocomplete from '../src/Autocomplete';
 import mocker from './helper/new-mocker';
 
 describe('<Autocomplete />', () => {
-  const autocompleteInitMock = jest.fn();
   const data = {
     Apple: null,
     Microsoft: null,
@@ -15,10 +14,6 @@ describe('<Autocomplete />', () => {
 
   let wrapper;
 
-  beforeEach(() => {
-    autocompleteInitMock.mockClear();
-  });
-
   test('renders', () => {
     wrapper = shallow(
       <Autocomplete title="Test Title" id={componentId} options={{ data }} />
@@ -40,6 +35,7 @@ describe('<Autocomplete />', () => {
   });
 
   describe('initialises', () => {
+    const autocompleteInitMock = jest.fn();
     const autocompleteInstanceDestroyMock = jest.fn();
     const autocompleteMock = {
       init: (el, options) => {
@@ -60,32 +56,4 @@ describe('<Autocomplete />', () => {
       expect(autocompleteInitMock).toHaveBeenCalledTimes(1);
     });
   });
-
-  describe('with new data', () => {
-    test('reinitializes', () => {
-      const wrapper = mount(<Autocomplete />);
-
-      expect(autocompleteInitMock).lastCalledWith({
-        data: {},
-        limit: Infinity,
-        minLength: 1,
-        onAutocomplete: null,
-        sortFunction: null
-      });
-
-      wrapper.setProps({
-        options: {
-          data: {
-            foo: 'bar'
-          }
-        }
-      });
-
-      expect(autocompleteInitMock).lastCalledWith({
-        data: {
-          foo: 'bar'
-        }
-      });
-    });
-  });
 });
diff --git a/test/Modal.spec.js b/test/Modal.spec.js
index bb39c9e..36d6c00 100644
--- a/test/Modal.spec.js
+++ b/test/Modal.spec.js
@@ -45,7 +45,7 @@ describe('<Modal />', () => {
     });
 
     test('calls Modal', () => {
-      shallow(<Modal />);
+      mount(<Modal />);
 
       expect(modalInitMock).toHaveBeenCalledTimes(1);
     });
@@ -95,7 +95,7 @@ describe('<Modal />', () => {
   describe('controlled modal with `open` prop', () => {
     beforeEach(() => {
       // initially set to open
-      wrapper = shallow(<Modal open>{children}</Modal>);
+      wrapper = mount(<Modal open>{children}</Modal>);
     });
 
     afterEach(() => {
diff --git a/test/Select.spec.js b/test/Select.spec.js
index 256d591..eb8c5a3 100644
--- a/test/Select.spec.js
+++ b/test/Select.spec.js
@@ -39,31 +39,16 @@ describe('<Select />', () => {
     expect(wrapper.find('select.validate')).toHaveLength(1);
   });
 
-  test('handles className', () => {
-    wrapper = shallow(<Select validate className={'hello red'} />);
+  test('handles selectClassName', () => {
+    wrapper = shallow(<Select validate selectClassName={'hello red'} />);
     expect(wrapper.find('select.hello.red.validate')).toHaveLength(1);
   });
 
-  // test('calls onChange if provided', () => {
-  //   const onChangeMock = jest.fn();
-  //   wrapper = mount(
-  //     <Select browserDefault onChange={onChangeMock} value="">
-  //       <option value="" disabled>
-  //         Choose your option
-  //       </option>
-  //       <option value="1">Option 1</option>
-  //       <option value="2">Option 2</option>
-  //       <option value="3">Option 3</option>
-  //     </Select>
-  //   );
-
-  //   wrapper
-  //     .find('option')
-  //     .at(2)
-  //     .simulate('click');
-
-  //   expect(onChangeMock).toHaveBeenCalled();
-  // });
+  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" />);
diff --git a/test/__snapshots__/Autocomplete.spec.js.snap b/test/__snapshots__/Autocomplete.spec.js.snap
index f7481a4..6fac4cc 100644
--- a/test/__snapshots__/Autocomplete.spec.js.snap
+++ b/test/__snapshots__/Autocomplete.spec.js.snap
@@ -7,7 +7,9 @@ exports[`<Autocomplete /> renders 1`] = `
   <input
     className="autocomplete"
     id="testAutocompleteId"
+    onChange={[Function]}
     type="text"
+    value=""
   />
   <label
     htmlFor="testAutocompleteId"
diff --git a/test/__snapshots__/Modal.spec.js.snap b/test/__snapshots__/Modal.spec.js.snap
index afd027d..7a53e6d 100644
--- a/test/__snapshots__/Modal.spec.js.snap
+++ b/test/__snapshots__/Modal.spec.js.snap
@@ -18,6 +18,7 @@ exports[`<Modal /> renders a modal has children 1`] = `
   fixedFooter={false}
   header="Modal header"
   id="Modal-mockId"
+  open={false}
   options={
     Object {
       "dismissible": true,
@@ -124,6 +125,7 @@ exports[`<Modal /> renders a trigger renders 1`] = `
   fixedFooter={false}
   header="Modal header"
   id="Modal-mockId"
+  open={false}
   options={
     Object {
       "dismissible": true,
@@ -229,6 +231,7 @@ exports[`<Modal /> without a trigger renders 1`] = `
   bottomSheet={false}
   fixedFooter={false}
   id="Modal-mockId"
+  open={false}
   options={
     Object {
       "dismissible": true,
diff --git a/test/__snapshots__/Select.spec.js.snap b/test/__snapshots__/Select.spec.js.snap
index 9b23070..3587c2d 100644
--- a/test/__snapshots__/Select.spec.js.snap
+++ b/test/__snapshots__/Select.spec.js.snap
@@ -10,8 +10,27 @@ exports[`<Select /> with icon 1`] = `
   <select
     className=""
     id="Select-mockId"
-    multiple={false}
     onChange={[Function]}
+    options={
+      Object {
+        "classes": "",
+        "dropdownOptions": Object {
+          "alignment": "left",
+          "autoTrigger": true,
+          "closeOnClick": true,
+          "constrainWidth": true,
+          "container": null,
+          "coverTrigger": true,
+          "hover": false,
+          "inDuration": 150,
+          "onCloseEnd": null,
+          "onCloseStart": null,
+          "onOpenEnd": null,
+          "onOpenStart": null,
+          "outDuration": 250,
+        },
+      }
+    }
     type="select"
   />
 </div>