Repository: mlaursen/react-md
Pull request #111 (2016-06-17 16:29:11)

Feature/file inputs
This solve #101 and some small bugfixes to Select Fields/Menus.


Patch:
diff --git a/src/js/FileInputs/FileInput.js b/src/js/FileInputs/FileInput.js
new file mode 100644
index 0000000000..3e04a860ae
--- /dev/null
+++ b/src/js/FileInputs/FileInput.js
@@ -0,0 +1,159 @@
+import React, { Component, PropTypes } from 'react';
+import PureRenderMixin from 'react-addons-pure-render-mixin';
+import classnames from 'classnames';
+
+import injectInk from '../Inks';
+import FontIcon from '../FontIcons';
+
+/**
+ * The `FileInput` component is used as simple styling for the `<input type="file" />`.
+ * It will style the input as a raised button by default.
+ */
+class FileInput extends Component {
+  constructor(props) {
+    super(props);
+
+    this.shouldComponentUpdate = PureRenderMixin.shouldComponentUpdate.bind(this);
+  }
+
+  static propTypes = {
+    /**
+     * An optional style to apply.
+     */
+    style: PropTypes.object,
+
+    /**
+     * An optional className to apply.
+     */
+    className: PropTypes.string,
+
+    /**
+     * Boolean if the `FileInput` should be styled with the primary color.
+     */
+    primary: PropTypes.bool,
+
+    /**
+     * Boolean if the `FileInput` should be styled with the secondary color.
+     */
+    secondary: PropTypes.bool,
+
+    /**
+     * Boolean if the `FileInput` should be styled as a flat button instead of a
+     * raised button.
+     */
+    flat: PropTypes.bool,
+
+    /**
+     * This should be a comma separated list of Media Types that the `FileInput` can
+     * accept. If this prop is left blank, any file will be accepted.
+     *
+     * The values can either be:
+     * - A file extension
+     * - audio/*
+     * - video/*
+     * - image/*
+     * - any valid [IANA Media Type](http://www.iana.org/assignments/media-types/media-types.xhtml)
+     */
+    accept: PropTypes.string,
+
+    /**
+     * Boolean if multiple files will be accepted.
+     */
+    multiple: PropTypes.bool,
+
+    /**
+     * A label to display on the `FileInput` when no files have been selected.
+     */
+    label: PropTypes.string.isRequired,
+
+    /**
+     * The icon children to use for the upload icon.
+     */
+    iconChildren: PropTypes.node,
+
+    /**
+     * The icon className to use for the upload icon.
+     */
+    iconClassName: PropTypes.string,
+
+    /**
+     * A function to call when the value of the input changes. This will
+     * be triggered when the user selects a new file or cancels the new file selection.
+     *
+     * This function will be given the new [FileList](https://developer.mozilla.org/en-US/docs/Web/API/FileList)
+     * as an array and the change event. If this is not a multiple file input, only the newly selected File
+     * will be given instead of a list of one file. Since this is an `input` tag,
+     * the user will not be able to select the same file multiple times unless
+     * you manually clear the input's value.
+     *
+     * > NOTE: If the user hits cancel, null will be given for a single file input.
+     *
+     * ```js
+     * onChange(files, e);
+     * ```
+     */
+    onChange: PropTypes.func.isRequired,
+
+    /**
+     * Injected from `injectInk`
+     */
+    ink: PropTypes.node,
+  };
+
+  static defaultProps = {
+    label: 'Select a file from your computer',
+    iconChildren: 'file_upload',
+  };
+
+  _handleChange = (e) => {
+    const { multiple, onChange } = this.props;
+    const { files } = e.target;
+    if(!multiple) {
+      onChange(files[0] || null, e);
+    } else {
+      onChange(Array.prototype.slice.call(files), e);
+    }
+  };
+
+  render() {
+    const {
+      style,
+      className,
+      label,
+      iconChildren,
+      iconClassName,
+      primary,
+      secondary,
+      flat,
+      ink,
+      ...props,
+    } = this.props;
+    delete props.onChange;
+
+    return (
+      <label
+        style={style}
+        className={classnames(`md-btn md-${flat ? 'flat' : 'raised'}-btn md-file-input-btn`, className, {
+          'md-primary': primary,
+          'md-secondary': secondary,
+        })}
+        disabled={props.disabled}
+      >
+        {ink}
+        <input
+          {...props}
+          aria-hidden="true"
+          type="file"
+          className="md-file-input"
+          onChange={this._handleChange}
+        />
+        <div className="icon-separator">
+          <FontIcon iconClassName={iconClassName} children={iconChildren} />
+          <span className="text">{label}</span>
+        </div>
+      </label>
+    );
+  }
+}
+
+export default injectInk(FileInput);
diff --git a/src/js/FileInputs/FileUpload.js b/src/js/FileInputs/FileUpload.js
new file mode 100644
index 0000000000..04ff3e0752
--- /dev/null
+++ b/src/js/FileInputs/FileUpload.js
@@ -0,0 +1,339 @@
+import React, { Component, PropTypes } from 'react';
+import { findDOMNode } from 'react-dom';
+import PureRenderMixin from 'react-addons-pure-render-mixin';
+import FileInput from './FileInput';
+
+/**
+ * The `FileUpload` component is used to upload files locally. If you want
+ * to upload files to a server, use [FormData](https://developer.mozilla.org/en-US/docs/Web/API/FormData)
+ * by attaching the `File`.
+ *
+ * Quick example:
+ *
+ * ```js
+ * function upload(file) {
+ *   fetch('/api/images', {
+ *     method: 'POST',
+ *     body: new FormData().append('file', file),
+ *   });
+ * }
+ * ```
+ *
+ * An upload can be aborted by calling the `abort(file || fileName)` function. If
+ * the file or fileName are ommitted, it will *attempt* to abort the current
+ * file that is uploading. Unreliable for multiselect.
+ *
+ * ```js
+ * <FileUpload ref="upload" />
+ * <RaisedButton onClick={() => this.refs.upload.abort()} label="Abort! Abort!" />
+ * ```
+ */
+export default class FileUpload extends Component {
+  constructor(props) {
+    super(props);
+
+    this.shouldComponentUpdate = PureRenderMixin.shouldComponentUpdate.bind(this);
+    this.state = {};
+  }
+
+  static propTypes = {
+    /**
+     * An optional style to apply.
+     */
+    style: PropTypes.object,
+
+    /**
+     * An optional className to apply.
+     */
+    className: PropTypes.string,
+
+    /**
+     * Boolean if the `FileInput` should be styled with the primary color.
+     */
+    primary: PropTypes.bool,
+
+    /**
+     * Boolean if the `FileInput` should be styled with the secondary color.
+     */
+    secondary: PropTypes.bool,
+
+    /**
+     * Boolean if the `FileInput` should be styled as a flat button instead of a
+     * raised button.
+     */
+    flat: PropTypes.bool,
+
+    /**
+     * This should be a comma separated list of Media Types that the `FileInput` can
+     * accept. If this prop is left blank, any file will be accepted.
+     *
+     * The values can either be:
+     * - A file extension
+     * - audio/*
+     * - video/*
+     * - image/*
+     * - any valid [IANA Media Type](http://www.iana.org/assignments/media-types/media-types.xhtml)
+     */
+    accept: PropTypes.string,
+
+    /**
+     * Boolean if multiple files will be accepted.
+     */
+    multiple: PropTypes.bool,
+
+    /**
+     * A label to display on the `FileInput`.
+     */
+    label: PropTypes.string,
+
+    /**
+     * The icon children to use for the upload icon.
+     */
+    iconChildren: PropTypes.node,
+
+    /**
+     * The icon className to use for the upload icon.
+     */
+    iconClassName: PropTypes.string,
+
+    /**
+     * An optional max size for the file. If the file is greater than
+     * this limit, the file will not be uploaded.
+     */
+    maxSize: PropTypes.number,
+
+    /**
+     * A required function to call when the `maxSize` prop is set. It will
+     * be given a list of files that were too big.
+     */
+    onSizeError: (props, propName, component) => {
+      if(typeof props.maxSize === 'number') {
+        return PropTypes.func.isRequired(props, propName, component);
+      }
+    },
+
+    /**
+     * You can force the `FileReader` to read the file as a specific type
+     * if you do not trust the *amazing* regex I have for choosing the correct
+     * one.
+     *
+     * ```js
+     * if(type.match(/image|video|audio/)) {
+     *   fr.readAsDataURL(file);
+     * } else if(type.match(/application|model|multipart/)) {
+     *   fr.readAsArrayBuffer(file);
+     * } else {
+     *   fr.readAsText(file);
+     * }
+     * ```
+     *
+     * > `.yml` and `.js` both are considered `application`, so it definitely fails there.
+     *
+     * If this prop is a function, you will be given the file's type, the file object, and
+     * the file reader. You will then need to call `fileReader.readAsYOUR_CORRECT_TYPE(file)`.
+     */
+    readAs: PropTypes.oneOfType([
+      PropTypes.oneOf(['DataURL', 'ArrayBuffer', 'Text']),
+      PropTypes.func,
+    ]),
+
+    /**
+     * An optional function to call when the `FileUpload` aborts. The current
+     * file and the abort event are given. This might not be the most useful
+     * function to use since you will need to manually call abort yourself anyways.
+     */
+    onAbort: PropTypes.func,
+
+    /**
+     * An optional function to call when the `FileUpload` errors. The current
+     * file, the error, and the error event are given.
+     *
+     * ```js
+     * onError(file, event.target.error, event);
+     * ```
+     */
+    onError: PropTypes.func,
+
+    /**
+     * An optional function to call when the `FileUpload` loads. The current
+     * file, the load result, and the load event are given.
+     *
+     * ```js
+     * onLoad(file, event.target.result, event);
+     * ```
+     *
+     * The load result will either be:
+     * - a data url
+     * - a plain text string
+     * - an array buffer
+     *
+     * depending on what type the file is.
+     */
+    onLoad: PropTypes.func,
+
+    /**
+     * An optional function to call when the `FileUpload` starts loading. The current
+     * file and the load start event are given.
+     */
+    onLoadStart: PropTypes.func,
+
+    /**
+     * An optional function to call when the `FileUpload` finishes loading. The
+     * current file and the load end event are given.
+     */
+    onLoadEnd: PropTypes.func,
+
+    /**
+     * An optional function to call when the `FileUpload` progress. The current
+     * file, upload progress, and the progress event are given. The progress
+     * will be a number between 0 and 100 that has not been rounded.
+     *
+     * ```js
+     * onProgress(file, progress, event);
+     * ```
+     */
+    onProgress: PropTypes.func,
+
+    /**
+     * An optional function to call when a file selects or unselects a file.
+     * This will be called before any local uploading occurs.
+     */
+    onChange: PropTypes.func,
+  };
+
+  /**
+   * Aborts the upload of a file. This either takes a file or a fileName and calls the
+   * `abort` function for that file's `FileReader`.
+   *
+   * @param {Object|String} file? The file or the file name to use to find the correct `FileReader`.
+   */
+  abort = (file) => {
+    let fileName = file;
+    if(!file) {
+      // Attempt to remove first file added...
+      fileName = Object.keys(this.state)[0];
+    } else if(typeof file.name === 'string') {
+      fileName = file.name;
+    }
+
+    const reader = this.state[fileName];
+    if(reader) {
+      reader.abort();
+      const state = this.state;
+      delete state[fileName];
+
+      findDOMNode(this).querySelector('.md-file-input').value = '';
+
+      this.setState(state);
+    }
+  };
+
+  _uploadFile = (file) => {
+    const {
+      onAbort,
+      onError,
+      onLoad,
+      onLoadStart,
+      onLoadEnd,
+      onProgress,
+      readAs,
+    } = this.props;
+
+    const { name, type } = file;
+
+    const fr = new FileReader();
+    if(onError) {
+      fr.onerror = e => {
+        onError(file, e.target.error, e);
+      };
+    }
+
+    if(onAbort) {
+      fr.onabort = e => {
+        onAbort(file, e);
+      };
+    }
+
+    if(onLoadStart) {
+      fr.onloadstart = e => {
+        onLoadStart(file, e);
+      };
+    }
+
+    if(onLoadEnd) {
+      fr.onloadend = e => {
+        onLoadEnd(file, e);
+      };
+    }
+
+    fr.onload = e => {
+      if(onLoad) {
+        onLoad(file, e.target.result, e);
+      }
+
+      const state = Object.assign({}, this.state);
+      delete state[name];
+      this.setState(state);
+    };
+
+    if(onProgress) {
+      fr.onprogress = e => {
+        if(e.lengthComputable) {
+          onProgress(file, (e.loaded / e.total) * 100, e);
+        }
+      };
+    }
+
+    if(readAs) {
+      if(typeof readAs === 'function') {
+        readAs(file.type, file, fr);
+      } else {
+        fr['readAs' + readAs](file);
+      }
+    } else if(type.match(/image|video|audio/)) {
+      fr.readAsDataURL(file);
+    } else if(type.match(/application|model|multipart/)) {
+      fr.readAsArrayBuffer(file);
+    } else {
+      fr.readAsText(file);
+    }
+
+    return fr;
+  };
+
+  _handleUpload = (files, e) => {
+    this.props.onChange && this.props.onChange(files, e);
+
+    if(!files) { return; }
+    const { maxSize, onSizeError } = this.props;
+    files = Array.isArray(files) ? files : [files];
+
+    let errorFiles = [];
+    if(maxSize) {
+      errorFiles = files.filter(file => file.size > maxSize);
+      files = files.filter(file => file.size <= maxSize);
+    }
+
+    if(errorFiles.length) {
+      onSizeError(errorFiles);
+    }
+
+    if(!files.length) {
+      return;
+    }
+
+    const nextState = {};
+    files.forEach(file => {
+      const fileReader = this._uploadFile(file);
+      nextState[file.name] = fileReader;
+    });
+
+    this.setState(nextState);
+  };
+
+  render() {
+    return (
+      <FileInput {...this.props} onChange={this._handleUpload} />
+    );
+  }
+}
diff --git a/src/js/FileInputs/index.js b/src/js/FileInputs/index.js
new file mode 100644
index 0000000000..82ddae34d6
--- /dev/null
+++ b/src/js/FileInputs/index.js
@@ -0,0 +1,7 @@
+import FileInput from './FileInput';
+export default FileInput;
+
+import FileUpload from './FileUpload';
+
+export { FileInput };
+export { FileUpload };
diff --git a/src/js/SelectFields/SelectField.js b/src/js/SelectFields/SelectField.js
index 89615aeadd..450ff7ad37 100644
--- a/src/js/SelectFields/SelectField.js
+++ b/src/js/SelectFields/SelectField.js
@@ -594,6 +594,7 @@ export default class SelectField extends Component {
       }),
       listClassName: classnames('md-select-field-menu', listClassName, {
         'single-line': !floatingLabel,
+        'full-width': fullWidth,
       }),
       toggle,
       listStyle,
diff --git a/src/js/index.js b/src/js/index.js
index becd67fa68..350d854b94 100644
--- a/src/js/index.js
+++ b/src/js/index.js
@@ -7,6 +7,7 @@ import { DataTable, TableHeader, TableBody, TableRow, TableColumn, EditDialogCol
 import Dialog from './Dialogs';
 import Divider from './Dividers';
 import { SpeedDial } from './FABTransitions';
+import FileInput, { FileUpload } from './FileInput';
 import FontIcon from './FontIcons';
 import Ink from './Inks';
 import { List, ListItem } from './Lists';
@@ -45,6 +46,8 @@ export { Divider };
 export { EditDialogColumn };
 export { FlatButton };
 export { FloatingButton };
+export { FileInput };
+export { FileUpload };
 export { FontIcon };
 export { IconButton };
 export { Ink };
@@ -104,6 +107,8 @@ export default {
   Chip,
   Dialog,
   Divider,
+  FileInput,
+  FileUpload,
   FontIcon,
   Menu,
   NavigationDrawer,
diff --git a/src/scss/components/_buttons.scss b/src/scss/components/_buttons.scss
index 204e29e3e8..4912484bcb 100644
--- a/src/scss/components/_buttons.scss
+++ b/src/scss/components/_buttons.scss
@@ -92,3 +92,12 @@ a.md-btn {
     color: inherit;
   }
 }
+
+.md-file-input {
+  height: 0;
+  opacity: 0;
+  overflow: hidden;
+  position: absolute;
+  width: 0;
+  z-index: -1;
+}
diff --git a/src/scss/components/_cards.scss b/src/scss/components/_cards.scss
index 074db5ec43..74d557b275 100644
--- a/src/scss/components/_cards.scss
+++ b/src/scss/components/_cards.scss
@@ -138,6 +138,7 @@
 }
 
 .md-card-media {
+  overflow: hidden;
   position: relative;
 }
 
diff --git a/src/scss/components/_menus.scss b/src/scss/components/_menus.scss
index 0abf56a9b4..3fb8e55374 100644
--- a/src/scss/components/_menus.scss
+++ b/src/scss/components/_menus.scss
@@ -41,6 +41,10 @@
     }
   }
 
+  &.full-width {
+    @extend %full-width;
+  }
+
   .md-menu-container {
     position: absolute;
   }


Test patch:
diff --git a/src/js/FileInputs/__tests__/FileInput.js b/src/js/FileInputs/__tests__/FileInput.js
new file mode 100644
index 0000000000..58f0c269f9
--- /dev/null
+++ b/src/js/FileInputs/__tests__/FileInput.js
@@ -0,0 +1,92 @@
+/*eslint-env jest*/
+jest.unmock('../FileInput');
+
+import React from 'react';
+import { findDOMNode } from 'react-dom';
+import {
+  Simulate,
+  renderIntoDocument,
+  findRenderedDOMComponentWithTag,
+} from 'react-addons-test-utils';
+
+import FileInput from '../FileInput';
+
+const File = jest.fn(name => ({
+  name: name || 'Test.jpg',
+  lastModifiedDate: new Date(),
+  isClosed: true,
+  size: 39202,
+  type: 'image/jpg',
+}));
+
+describe('FileInput', () => {
+  it('merges className and style', () => {
+    const style = { display: 'block' };
+    const className = 'test';
+    const fileInput = renderIntoDocument(
+      <FileInput style={style} className={className} onChange={jest.fn()} />
+    );
+
+    const fileInputNode = findDOMNode(fileInput);
+    expect(fileInputNode.style.display).toBe(style.display);
+    expect(fileInputNode.className).toContain(className);
+  });
+
+  it('returns a single file when multiple is false onChange', () => {
+    const onChange = jest.fn();
+    const fileInput = renderIntoDocument(<FileInput onChange={onChange} />);
+
+    const input = findRenderedDOMComponentWithTag(fileInput, 'input');
+
+    // can't instantiate fully, so we can make an almost exact reference
+    const files = [new File()];
+
+    Simulate.change(input, { target: { files } });
+
+    expect(onChange.mock.calls.length).toBe(1);
+    expect(onChange.mock.calls[0][0]).toEqual(files[0]);
+  });
+
+  it('returns null when the user cancel\'s a file selection when multiple is false onChange', () => {
+    const onChange = jest.fn();
+    const fileInput = renderIntoDocument(<FileInput onChange={onChange} />);
+
+    const input = findRenderedDOMComponentWithTag(fileInput, 'input');
+    // Cancel returns empty list
+    const files = [];
+
+    Simulate.change(input, { target: { files } });
+
+    expect(onChange.mock.calls.length).toBe(1);
+    expect(onChange.mock.calls[0][0]).toBe(null);
+  });
+
+  it('returns a list of files when multiple is true onChange', () => {
+    const onChange = jest.fn();
+    const fileInput = renderIntoDocument(<FileInput onChange={onChange} multiple={true} />);
+
+    const input = findRenderedDOMComponentWithTag(fileInput, 'input');
+
+    // can't instantiate fully, so we can make an almost exact reference
+    const files = [new File(), new File('Test2.jpg')];
+
+    Simulate.change(input, { target: { files } });
+
+    expect(onChange.mock.calls.length).toBe(1);
+    expect(onChange.mock.calls[0][0]).toEqual(files);
+  });
+
+  it('returns an empty list of files when the user cancels an upload and multiple is true onChange', () => {
+    const onChange = jest.fn();
+    const fileInput = renderIntoDocument(<FileInput onChange={onChange} multiple={true} />);
+
+    const input = findRenderedDOMComponentWithTag(fileInput, 'input');
+    const files = [];
+
+    Simulate.change(input, { target: { files } });
+
+    expect(onChange.mock.calls.length).toBe(1);
+    expect(onChange.mock.calls[0][0].length).toBe(0);
+    expect(onChange.mock.calls[0][0]).toEqual(files);
+  });
+});
diff --git a/src/js/FileInputs/__tests__/FileUpload.js b/src/js/FileInputs/__tests__/FileUpload.js
new file mode 100644
index 0000000000..0b12986be5
--- /dev/null
+++ b/src/js/FileInputs/__tests__/FileUpload.js
@@ -0,0 +1,380 @@
+/*eslint-env jest, jasmine*/
+jest.unmock('../FileUpload');
+jest.unmock('../FileInput');
+
+import React from 'react';
+import { findDOMNode } from 'react-dom';
+import {
+  Simulate,
+  renderIntoDocument,
+  findRenderedDOMComponentWithTag,
+} from 'react-addons-test-utils';
+
+import FileUpload from '../FileUpload';
+
+const File = jest.fn((size, type) => ({
+  name: 'Test.jpg',
+  lastModifiedDate: new Date(),
+  isClosed: true,
+  size: size || 1024,
+  type: typeof type === 'string' ? type : 'image/jpg',
+}));
+
+describe('FileUpload', () => {
+  it('merges className and style', () => {
+    const style = { display: 'block' };
+    const className = 'test';
+    const fileUpload = renderIntoDocument(
+      <FileUpload style={style} className={className} onChange={jest.fn()} />
+    );
+
+    const fileUploadNode = findDOMNode(fileUpload);
+    expect(fileUploadNode.style.display).toBe(style.display);
+    expect(fileUploadNode.className).toContain(className);
+  });
+
+  it('calls the onChange function still', () => {
+    const onChange = jest.fn();
+    const fileUpload = renderIntoDocument(<FileUpload onChange={onChange} />);
+
+    const input = findRenderedDOMComponentWithTag(fileUpload, 'input');
+    const files = [new File()];
+
+    Simulate.change(input, { target: { files }});
+    expect(onChange.mock.calls.length).toBe(1);
+    expect(onChange.mock.calls[0][0]).toEqual(files[0]);
+  });
+
+  it('prevents any files with a size greater than the maxSize', () => {
+    const onSizeError = jest.fn();
+    const fileUpload = renderIntoDocument(<FileUpload maxSize={1024} onSizeError={onSizeError} />);
+
+    const input = findRenderedDOMComponentWithTag(fileUpload, 'input');
+    let files = [new File()];
+
+    Simulate.change(input, { target: { files }});
+    expect(onSizeError.mock.calls.length).toBe(0);
+
+    files = [new File(1025)];
+
+    Simulate.change(input, { target: { files }});
+    expect(onSizeError.mock.calls.length).toBe(1);
+    expect(onSizeError.mock.calls[0][0]).toEqual(files);
+  });
+
+  it('calls the onError prop with the current file, error, and error event', () => {
+    const eventListener = jasmine.createSpy();
+
+    const frMock = {
+      onerror: jest.fn(e => e),
+      readAsDataURL: jest.fn(function() {
+        this.onerror({ target: { error: new Error('Something went wrong') } });
+      }),
+      addEventListener: eventListener,
+    };
+
+    spyOn(window, 'FileReader').and.returnValue(frMock);
+
+    const onError = jest.fn();
+    const fileUpload = renderIntoDocument(<FileUpload onError={onError} />);
+    const input = findRenderedDOMComponentWithTag(fileUpload, 'input');
+    const files = [new File()];
+
+    Simulate.change(input, { target: { files }});
+    expect(onError.mock.calls.length).toBe(1);
+    expect(onError.mock.calls[0][0]).toEqual(files[0]);
+    expect(onError.mock.calls[0][1]).toEqual(new Error('Something went wrong'));
+    expect(onError.mock.calls[0][2]).toBeDefined();
+  });
+
+  it('calls the onAbort prop with the current file and the abort event', () => {
+    const eventListener = jasmine.createSpy();
+
+    const frMock = {
+      onabort: jest.fn(e => e),
+      readAsDataURL: jest.fn(function() {
+        this.onabort({});
+      }),
+      addEventListener: eventListener,
+    };
+
+    spyOn(window, 'FileReader').and.returnValue(frMock);
+
+    const onAbort = jest.fn();
+    const fileUpload = renderIntoDocument(<FileUpload onAbort={onAbort} />);
+    const input = findRenderedDOMComponentWithTag(fileUpload, 'input');
+    const files = [new File()];
+
+    Simulate.change(input, { target: { files }});
+    expect(onAbort.mock.calls.length).toBe(1);
+    expect(onAbort.mock.calls[0][0]).toEqual(files[0]);
+    expect(onAbort.mock.calls[0][1]).toBeDefined();
+  });
+
+  it('calls the onLoadStart prop with the current file and the load start event', () => {
+    const eventListener = jasmine.createSpy();
+
+    const frMock = {
+      onloadstart: jest.fn(e => e),
+      readAsDataURL: jest.fn(function() {
+        this.onloadstart({});
+      }),
+      addEventListener: eventListener,
+    };
+
+    spyOn(window, 'FileReader').and.returnValue(frMock);
+
+    const onLoadStart = jest.fn();
+    const fileUpload = renderIntoDocument(<FileUpload onLoadStart={onLoadStart} />);
+    const input = findRenderedDOMComponentWithTag(fileUpload, 'input');
+    const files = [new File()];
+
+    Simulate.change(input, { target: { files }});
+    expect(onLoadStart.mock.calls.length).toBe(1);
+    expect(onLoadStart.mock.calls[0][0]).toEqual(files[0]);
+    expect(onLoadStart.mock.calls[0][1]).toBeDefined();
+  });
+
+  it('calls the onLoadEnd prop with the current file and the load end event', () => {
+    const eventListener = jasmine.createSpy();
+
+    const frMock = {
+      onloadend: jest.fn(e => e),
+      readAsDataURL: jest.fn(function() {
+        this.onloadend({});
+      }),
+      addEventListener: eventListener,
+    };
+
+    spyOn(window, 'FileReader').and.returnValue(frMock);
+
+    const onLoadEnd = jest.fn();
+    const fileUpload = renderIntoDocument(<FileUpload onLoadEnd={onLoadEnd} />);
+    const input = findRenderedDOMComponentWithTag(fileUpload, 'input');
+    const files = [new File()];
+
+    Simulate.change(input, { target: { files }});
+    expect(onLoadEnd.mock.calls.length).toBe(1);
+    expect(onLoadEnd.mock.calls[0][0]).toEqual(files[0]);
+    expect(onLoadEnd.mock.calls[0][1]).toBeDefined();
+  });
+
+  it('calls the onLoad function with the file, the load result, and the load event', () => {
+    const eventListener = jasmine.createSpy();
+
+    const result = 'data:image/png;base64;hfuasdhfjawf';
+    const frMock = {
+      onload: jest.fn(e => e),
+      readAsDataURL: jest.fn(function() {
+        this.onload(({ target: { result }}));
+      }),
+      addEventListener: eventListener,
+    };
+
+    spyOn(window, 'FileReader').and.returnValue(frMock);
+
+    const onLoad = jest.fn();
+    const fileUpload = renderIntoDocument(<FileUpload onLoad={onLoad} />);
+    const input = findRenderedDOMComponentWithTag(fileUpload, 'input');
+    const files = [new File()];
+
+    Simulate.change(input, { target: { files }});
+    expect(onLoad.mock.calls.length).toBe(1);
+    expect(onLoad.mock.calls[0][0]).toBe(files[0]);
+    expect(onLoad.mock.calls[0][1]).toBe(result);
+    expect(onLoad.mock.calls[0][2]).toBeDefined();
+  });
+
+  it('calls the onProgress function with the file, percentage complete, and the progress event', () => {
+    const eventListener = jasmine.createSpy();
+
+    const frMock = {
+      onprogress: jest.fn(e => e),
+      readAsDataURL: jest.fn(function(file) {
+        this.onprogress(({ lengthComputable: true, loaded: 22, total: file.size }));
+      }),
+      addEventListener: eventListener,
+    };
+
+    spyOn(window, 'FileReader').and.returnValue(frMock);
+
+    const onProgress = jest.fn();
+    const fileUpload = renderIntoDocument(<FileUpload onProgress={onProgress} />);
+    const input = findRenderedDOMComponentWithTag(fileUpload, 'input');
+    const files = [new File()];
+
+    Simulate.change(input, { target: { files }});
+    expect(onProgress.mock.calls.length).toBe(1);
+    expect(onProgress.mock.calls[0][0]).toBe(files[0]);
+    expect(onProgress.mock.calls[0][1]).toBe((22 / files[0].size) * 100);
+    expect(onProgress.mock.calls[0][2]).toBeDefined();
+  });
+
+  it('it matches image, video, and audio to the readAsDataURL FileReader function', () => {
+    const eventListener = jasmine.createSpy();
+
+    const frMock = {
+      readAsDataURL: jest.fn(),
+      readAsArrayBuffer: jest.fn(),
+      readAsText: jest.fn(),
+      addEventListener: eventListener,
+    };
+    spyOn(window, 'FileReader').and.returnValue(frMock);
+
+    const fileUpload = renderIntoDocument(<FileUpload />);
+    const input = findRenderedDOMComponentWithTag(fileUpload, 'input');
+    let files = [new File()];
+
+    Simulate.change(input, { target: { files }});
+
+    expect(frMock.readAsDataURL.mock.calls.length).toBe(1);
+    expect(frMock.readAsArrayBuffer.mock.calls.length).toBe(0);
+    expect(frMock.readAsText.mock.calls.length).toBe(0);
+
+    files = [new File(1024, 'video/mp4')];
+
+    Simulate.change(input, { target: { files }});
+
+    expect(frMock.readAsDataURL.mock.calls.length).toBe(2);
+    expect(frMock.readAsArrayBuffer.mock.calls.length).toBe(0);
+    expect(frMock.readAsText.mock.calls.length).toBe(0);
+
+    files = [new File(1024, 'audio/mp3')];
+
+    Simulate.change(input, { target: { files }});
+
+    expect(frMock.readAsDataURL.mock.calls.length).toBe(3);
+    expect(frMock.readAsArrayBuffer.mock.calls.length).toBe(0);
+    expect(frMock.readAsText.mock.calls.length).toBe(0);
+  });
+
+  it('matches application, model, and multipart to the readAsArrayBuffer FileReader function', () => {
+    const eventListener = jasmine.createSpy();
+
+    const frMock = {
+      readAsDataURL: jest.fn(),
+      readAsArrayBuffer: jest.fn(),
+      readAsText: jest.fn(),
+      addEventListener: eventListener,
+    };
+    spyOn(window, 'FileReader').and.returnValue(frMock);
+
+    const fileUpload = renderIntoDocument(<FileUpload />);
+    const input = findRenderedDOMComponentWithTag(fileUpload, 'input');
+    let files = [new File(1024, 'application/gzip')];
+
+    Simulate.change(input, { target: { files }});
+
+    expect(frMock.readAsDataURL.mock.calls.length).toBe(0);
+    expect(frMock.readAsArrayBuffer.mock.calls.length).toBe(1);
+    expect(frMock.readAsText.mock.calls.length).toBe(0);
+
+    // no idea what real model would be
+    files = [new File(1024, 'model/airoplaine')];
+
+    Simulate.change(input, { target: { files }});
+
+    expect(frMock.readAsDataURL.mock.calls.length).toBe(0);
+    expect(frMock.readAsArrayBuffer.mock.calls.length).toBe(2);
+    expect(frMock.readAsText.mock.calls.length).toBe(0);
+
+    // not sure what real multipart would be.
+    files = [new File(1024, 'multipart/form-data')];
+
+    Simulate.change(input, { target: { files }});
+
+    expect(frMock.readAsDataURL.mock.calls.length).toBe(0);
+    expect(frMock.readAsArrayBuffer.mock.calls.length).toBe(3);
+    expect(frMock.readAsText.mock.calls.length).toBe(0);
+  });
+
+  it('matches any remaining type to text', () => {
+    const eventListener = jasmine.createSpy();
+
+    const frMock = {
+      readAsDataURL: jest.fn(),
+      readAsArrayBuffer: jest.fn(),
+      readAsText: jest.fn(),
+      addEventListener: eventListener,
+    };
+    spyOn(window, 'FileReader').and.returnValue(frMock);
+
+    const fileUpload = renderIntoDocument(<FileUpload />);
+    const input = findRenderedDOMComponentWithTag(fileUpload, 'input');
+    let files = [new File(1024, 'text/x-java')];
+
+    Simulate.change(input, { target: { files }});
+
+    expect(frMock.readAsDataURL.mock.calls.length).toBe(0);
+    expect(frMock.readAsArrayBuffer.mock.calls.length).toBe(0);
+    expect(frMock.readAsText.mock.calls.length).toBe(1);
+
+    files = [new File(1024, '')];
+
+    Simulate.change(input, { target: { files }});
+
+    expect(frMock.readAsDataURL.mock.calls.length).toBe(0);
+    expect(frMock.readAsArrayBuffer.mock.calls.length).toBe(0);
+    expect(frMock.readAsText.mock.calls.length).toBe(2);
+  });
+
+  it('allows for a custom readAs string', () => {
+    const eventListener = jasmine.createSpy();
+
+    const frMock = {
+      readAsDataURL: jest.fn(),
+      readAsArrayBuffer: jest.fn(),
+      readAsText: jest.fn(),
+      addEventListener: eventListener,
+    };
+    spyOn(window, 'FileReader').and.returnValue(frMock);
+
+    let fileUpload = renderIntoDocument(<FileUpload readAs="DataURL" />);
+    let input = findRenderedDOMComponentWithTag(fileUpload, 'input');
+    const files = [new File(2024, 'application/javascript')];
+
+    Simulate.change(input, { target: { files }});
+    expect(frMock.readAsDataURL.mock.calls.length).toBe(1);
+    expect(frMock.readAsArrayBuffer.mock.calls.length).toBe(0);
+    expect(frMock.readAsText.mock.calls.length).toBe(0);
+
+    fileUpload = renderIntoDocument(<FileUpload readAs="ArrayBuffer" />);
+    input = findRenderedDOMComponentWithTag(fileUpload, 'input');
+
+    Simulate.change(input, { target: { files }});
+    expect(frMock.readAsDataURL.mock.calls.length).toBe(1);
+    expect(frMock.readAsArrayBuffer.mock.calls.length).toBe(1);
+    expect(frMock.readAsText.mock.calls.length).toBe(0);
+
+    fileUpload = renderIntoDocument(<FileUpload readAs="Text" />);
+    input = findRenderedDOMComponentWithTag(fileUpload, 'input');
+
+    Simulate.change(input, { target: { files }});
+    expect(frMock.readAsDataURL.mock.calls.length).toBe(1);
+    expect(frMock.readAsArrayBuffer.mock.calls.length).toBe(1);
+    expect(frMock.readAsText.mock.calls.length).toBe(1);
+  });
+
+  it('allows for a custom readAs function that accepts the file type, the file, and the current file reader', () => {
+    const eventListener = jasmine.createSpy();
+
+    const frMock = {
+      readAsDataURL: jest.fn(),
+      readAsArrayBuffer: jest.fn(),
+      readAsText: jest.fn(),
+      addEventListener: eventListener,
+    };
+    spyOn(window, 'FileReader').and.returnValue(frMock);
+
+    const readAs = jest.fn();
+    const fileUpload = renderIntoDocument(<FileUpload readAs={readAs} />);
+    const input = findRenderedDOMComponentWithTag(fileUpload, 'input');
+    const files = [new File()];
+
+    Simulate.change(input, { target: { files }});
+    expect(readAs.mock.calls.length).toBe(1);
+    expect(readAs.mock.calls[0][0]).toBe(files[0].type);
+    expect(readAs.mock.calls[0][1]).toEqual(files[0]);
+    expect(readAs.mock.calls[0][2]).toEqual(frMock);
+  });
+});