Repository: mlaursen/react-md
Pull request #106 (2016-06-14 16:03:38)

Feature/password fields
This pull request implements #103.

It also includes:
- Small bugfixes to text field placeholder/label colors.
- Focus api
- Test extraction

 


Patch:
diff --git a/src/js/TextFields/FloatingLabel.js b/src/js/TextFields/FloatingLabel.js
index 64f70408d8..8f850c832e 100644
--- a/src/js/TextFields/FloatingLabel.js
+++ b/src/js/TextFields/FloatingLabel.js
@@ -1,33 +1,43 @@
-import React, { PropTypes } from 'react';
+import React, { Component, PropTypes } from 'react';
+import PureRenderMixin from 'react-addons-pure-render-mixin';
 import classnames from 'classnames';
 
-const FloatingLabel = ({ active, error, label, required, value }) => {
-  if(required && label.indexOf('*') === -1) {
-    label = label.trim() + ' *';
+/**
+ * This component is used for rendering the floating label for a text field.
+ */
+export default class FloatingLabel extends Component {
+  constructor(props) {
+    super(props);
+
+    this.shouldComponentUpdate = PureRenderMixin.shouldComponentUpdate.bind(this);
   }
 
-  return (
-    <span
-      className={classnames('md-floating-label', {
-        'active': active || !!value,
-        'focus': active,
-        error,
-      })}
-    >
-      {label}
-    </span>
-  );
-};
+  static propTypes = {
+    active: PropTypes.bool.isRequired,
+    error: PropTypes.bool.isRequired,
+    label: PropTypes.string.isRequired,
+    required: PropTypes.bool,
+    disabled: PropTypes.bool,
+    value: PropTypes.oneOfType([
+      PropTypes.string,
+      PropTypes.number,
+    ]),
+  };
+
+  render() {
+    const { active, error, required, value, disabled } = this.props;
+    let { label } = this.props;
+    if(required && label.indexOf('*') === -1) {
+      label = label.trim() + ' *';
+    }
 
-FloatingLabel.propTypes = {
-  active: PropTypes.bool.isRequired,
-  error: PropTypes.bool.isRequired,
-  label: PropTypes.string.isRequired,
-  required: PropTypes.bool,
-  value: PropTypes.oneOfType([
-    PropTypes.string,
-    PropTypes.number,
-  ]),
-};
+    const className= classnames('md-floating-label', {
+      error,
+      disabled,
+      'focus': active,
+      'active': active || !!value,
+    });
 
-export default FloatingLabel;
+    return <span className={className}>{label}</span>;
+  }
+}
diff --git a/src/js/TextFields/TextDivider.js b/src/js/TextFields/TextDivider.js
index f422b662ff..c761f2b72d 100644
--- a/src/js/TextFields/TextDivider.js
+++ b/src/js/TextFields/TextDivider.js
@@ -1,23 +1,33 @@
-import React, { PropTypes } from 'react';
+import React, { Component, PropTypes } from 'react';
+import PureRenderMixin from 'react-addons-pure-render-mixin';
 import classnames from 'classnames';
 
-const TextDivider = ({ lineDirection, active, error, icon }) => {
-  return (
-    <div
-      className={classnames('md-text-divider', `from-${lineDirection}`, {
-        active,
-        error,
-        'icon-offset': icon,
-      })}
-    />
-  );
-};
+/**
+ * The `TextDivider` component renders the divider below the `TextField` component.
+ */
+export default class TextDivider extends Component {
+  constructor(props) {
+    super(props);
 
-TextDivider.propTypes = {
-  lineDirection: PropTypes.oneOf(['left', 'center', 'right']).isRequired,
-  active: PropTypes.bool.isRequired,
-  error: PropTypes.bool.isRequired,
-  icon: PropTypes.bool.isRequired,
-};
+    this.shouldComponentUpdate = PureRenderMixin.shouldComponentUpdate.bind(this);
+  }
 
-export default TextDivider;
+  static propTypes = {
+    lineDirection: PropTypes.oneOf(['left', 'center', 'right']).isRequired,
+    active: PropTypes.bool.isRequired,
+    error: PropTypes.bool.isRequired,
+    icon: PropTypes.bool.isRequired,
+  };
+
+  render() {
+    const { lineDirection, active, error, icon } = this.props;
+    const className = classnames('md-text-divider', `from-${lineDirection}`, {
+      active,
+      error,
+      'icon-offset': icon,
+    });
+    return (
+      <div className={className} />
+    );
+  }
+}
diff --git a/src/js/TextFields/TextField.js b/src/js/TextFields/TextField.js
index 04bea0657f..5d2b179dba 100644
--- a/src/js/TextFields/TextField.js
+++ b/src/js/TextFields/TextField.js
@@ -1,10 +1,12 @@
 import React, { Component, PropTypes } from 'react';
+import { findDOMNode } from 'react-dom';
 import PureRenderMixin from 'react-addons-pure-render-mixin';
 import classnames from 'classnames';
 
 import FloatingLabel from './FloatingLabel';
 import TextDivider from './TextDivider';
 import TextFieldMessage from './TextFieldMessage';
+import FontIcon from '../FontIcons';
 
 const valueType = PropTypes.oneOfType([
   PropTypes.string,
@@ -18,6 +20,15 @@ const valueType = PropTypes.oneOfType([
  * Text Fields display as `inline-block` by default so that their size does not span `100%`. If
  * you want a text field per-line, wrap them in a div, or set them to display block (will make their width
  * expand as well though).
+ *
+ * There is a publically accessible `focus()` function to simplify the focusing of the text field.
+ *
+ * It can be used as follows:
+ *
+ * ```js
+ * <TextField ref="textField" />
+ * <button onClick={() => this.refs.textField.focus()}>Focus Text Field</button>
+ * ```
  */
 export default class TextField extends Component {
   constructor(props) {
@@ -29,6 +40,7 @@ export default class TextField extends Component {
       currentRows: props.rows,
       areaHeight: 'auto',
       value: props.defaultValue,
+      passwordVisible: false,
     };
   }
 
@@ -226,6 +238,16 @@ export default class TextField extends Component {
      * Boolean if the this text field should span the full width of a parent.
      */
     fullWidth: PropTypes.bool,
+
+    /**
+     * Any children used to render the password icon button.
+     */
+    passwordIconChildren: PropTypes.node,
+
+    /**
+     * Any icon className to use to render the password icon button.
+     */
+    passwordIconClassName: PropTypes.string,
   };
 
   static defaultProps = {
@@ -233,6 +255,7 @@ export default class TextField extends Component {
     defaultValue: '',
     floatingLabel: true,
     lineDirection: 'left',
+    passwordIconChildren: 'remove_red_eye',
   };
 
   getValue = () => {
@@ -289,8 +312,24 @@ export default class TextField extends Component {
     this.setState(state);
   };
 
+  /**
+   * A publicly accessibly API for focusing the text field.
+   */
+  focus = () => {
+    if(!this.textField) {
+      this.textField = findDOMNode(this.refs.textField || this.refs.textarea);
+    }
+
+
+    this.textField.focus();
+  };
+
+  _togglePasswordField = () => {
+    this.setState({ passwordVisible: !this.state.passwordVisible });
+  };
+
   render() {
-    const { active, currentRows, areaHeight } = this.state;
+    const { active, currentRows, areaHeight, passwordVisible } = this.state;
     const {
       className,
       inputClassName,
@@ -302,6 +341,8 @@ export default class TextField extends Component {
       floatingLabel,
       icon,
       rightIcon,
+      passwordIconChildren,
+      passwordIconClassName,
       lineDirection,
       rows,
       maxRows,
@@ -338,6 +379,7 @@ export default class TextField extends Component {
     if(icon) {
       fontIcon = React.cloneElement(icon, {
         className: classnames('md-text-field-icon', {
+          disabled,
           active,
           error,
           'with-floating-label': useFloatingLabel,
@@ -352,6 +394,20 @@ export default class TextField extends Component {
           'single-line': !useFloatingLabel,
         }),
       });
+    } else if(type === 'password') {
+      indIcon = (
+        <button
+          type="button"
+          onClick={this._togglePasswordField}
+          className={classnames('md-password-btn', {
+            'active': passwordVisible,
+            'multi-line': useFloatingLabel,
+            'single-line': !useFloatingLabel,
+          })}
+        >
+          <FontIcon iconClassName={passwordIconClassName} children={passwordIconChildren} />
+        </button>
+      );
     }
 
     if(errorText || maxLength || helpText) {
@@ -391,7 +447,6 @@ export default class TextField extends Component {
       onSelect,
       readOnly,
       size,
-      type,
       value,
     };
 
@@ -408,21 +463,43 @@ export default class TextField extends Component {
         }
       }
 
+      let visiblePlaceholder;
+      if(active || !useFloatingLabel || block) {
+        visiblePlaceholder = placeholder || label;
+
+        if(required && visiblePlaceholder.indexOf('*') === -1) {
+          visiblePlaceholder = visiblePlaceholder.trim() + ' *';
+        }
+      }
+
       textField = (
         <textarea
           {...textFieldProps}
-          placeholder={active || !useFloatingLabel || block ? (placeholder || label) : null}
+          placeholder={visiblePlaceholder}
           ref="textarea"
           rows={rows}
           style={areaStyle}
         />
       );
     } else {
+      let visiblePlaceholder;
+      if(!useFloatingLabel) {
+        visiblePlaceholder = placeholder || label;
+
+        if(required && visiblePlaceholder.indexOf('*') === -1) {
+          visiblePlaceholder = visiblePlaceholder.trim() + ' *';
+        }
+      } else {
+        visiblePlaceholder = placeholder;
+      }
+
       textField = (
         <input
           {...textFieldProps}
+          ref="textField"
+          type={passwordVisible ? 'text': type}
           style={inputStyle}
-          placeholder={!useFloatingLabel ? (placeholder || label) : placeholder}
+          placeholder={visiblePlaceholder}
         />
       );
     }
@@ -431,6 +508,7 @@ export default class TextField extends Component {
       <div
         {...props}
         className={classnames('md-text-field-container', className, {
+          disabled,
           'multi-line': multiline,
           'block': block,
           'full-width': fullWidth,
@@ -446,6 +524,7 @@ export default class TextField extends Component {
             error={error}
             required={required}
             value={value}
+            disabled={disabled}
           />
           }
           {textField}
diff --git a/src/js/TextFields/TextFieldMessage.js b/src/js/TextFields/TextFieldMessage.js
index 03fb3305af..bc37f62006 100644
--- a/src/js/TextFields/TextFieldMessage.js
+++ b/src/js/TextFields/TextFieldMessage.js
@@ -1,41 +1,54 @@
-import React, { PropTypes } from 'react';
+import React, { Component, PropTypes } from 'react';
+import PureRenderMixin from 'react-addons-pure-render-mixin';
 import CSSTransitionGroup from 'react-addons-css-transition-group';
 import classnames from 'classnames';
 
-const TextFieldMessage = ({ value, message, maxLength, error, active, helpOnFocus, className }) => {
-  const isMessageVisible = !!message && (!helpOnFocus || active);
-  return (
-    <CSSTransitionGroup
-      component="div"
-      transitionName="opacity"
-      transitionEnterTimeout={150}
-      transitionLeaveTimeout={150}
-      className={classnames('md-text-field-message', className, {
-        error,
-        'count-only': !message || !isMessageVisible,
-      })}
-    >
-      {isMessageVisible && <span key="message">{message}</span>}
-      {maxLength &&
-      <span className="md-text-field-counter">
-        {`${value.length} / ${maxLength}`}
-      </span>
-      }
-    </CSSTransitionGroup>
-  );
-};
+/**
+ * The `TextFieldMessage` component is used for rendering a help or error text message
+ * under a `TextField`. It can also be used to display a counter of remaining characters.
+ */
+export default class TextFieldMessage extends Component {
+  constructor(props) {
+    super(props);
 
-TextFieldMessage.propTypes = {
-  value: PropTypes.oneOfType([
-    PropTypes.string,
-    PropTypes.number,
-  ]),
-  message: PropTypes.string,
-  maxLength: PropTypes.number,
-  error: PropTypes.bool.isRequired,
-  active: PropTypes.bool.isRequired,
-  helpOnFocus: PropTypes.bool,
-  className: PropTypes.string,
-};
+    this.shouldComponentUpdate = PureRenderMixin.shouldComponentUpdate.bind(this);
+  }
 
-export default TextFieldMessage;
+  static propTypes = {
+    value: PropTypes.oneOfType([
+      PropTypes.string,
+      PropTypes.number,
+    ]).isRequired,
+    message: PropTypes.string,
+    maxLength: PropTypes.number,
+    error: PropTypes.bool.isRequired,
+    active: PropTypes.bool.isRequired,
+    helpOnFocus: PropTypes.bool,
+    className: PropTypes.string,
+  };
+
+  render() {
+    const { value, message, maxLength, error, active, helpOnFocus, className } = this.props;
+    const isMessageVisible = !!message && (!helpOnFocus || active);
+
+    return (
+      <CSSTransitionGroup
+        component="div"
+        transitionName="opacity"
+        transitionEnterTimeout={150}
+        transitionLeaveTimeout={150}
+        className={classnames('md-text-field-message', className, {
+          error,
+          'count-only': !message || !isMessageVisible,
+        })}
+      >
+        {isMessageVisible && <span key="message">{message}</span>}
+        {maxLength &&
+        <span className="md-text-field-counter">
+          {`${value.length} / ${maxLength}`}
+        </span>
+        }
+      </CSSTransitionGroup>
+    );
+  }
+}
diff --git a/src/scss/components/_text-fields.scss b/src/scss/components/_text-fields.scss
index cb1fe8b867..f244b67ae3 100644
--- a/src/scss/components/_text-fields.scss
+++ b/src/scss/components/_text-fields.scss
@@ -1,6 +1,5 @@
 .md-text-field-container {
   @include md-theme-placeholders($md-secondary-text-color);
-
   display: inline-block;
   vertical-align: top;
 
@@ -39,6 +38,28 @@
   &.full-width {
     width: 100%;
   }
+
+  &.disabled {
+    @extend %md-disabled-text;
+    @include md-theme-placeholders($md-disabled-color);
+  }
+
+  // scss-lint:disable VendorPrefix, PseudoElement
+  :-moz-placeholder {
+    opacity: 1;
+  }
+
+  ::-moz-placeholder {
+    opacity: 1;
+  }
+
+  :-ms-input-placeholder {
+    opacity: 1;
+  }
+
+  ::-webkit-input-placeholder {
+    opacity: 1;
+  }
 }
 
 .md-text-field-label {
@@ -96,12 +117,9 @@
   position: absolute;
 }
 
-.md-floating-label,
-.md-text-field-icon {
-  color: $md-hint-text-color;
-}
-
 .md-floating-label {
+  @extend %md-secondary-text;
+
   cursor: text;
   pointer-events: none;
   top: $md-text-field-floating-label-top;
@@ -111,6 +129,10 @@
   &.active {
     font-size: $md-text-field-active-font-size;
   }
+
+  &.disabled {
+    @extend %md-disabled-text;
+  }
 }
 
 .md-text-divider {
@@ -146,12 +168,20 @@
 }
 
 .md-text-field-icon {
+  @extend %md-secondary-text;
+
   margin-right: $md-text-field-icon-margin;
   transition-duration: $md-transition-time;
   transition-property: color;
+
+  &.disabled {
+    @extend %md-disabled-text;
+  }
 }
 
 .md-text-field-message {
+  @extend %md-hint-text;
+
   display: flex;
   font-size: $md-text-field-info-font-size;
   justify-content: space-between;
@@ -180,3 +210,21 @@
 .md-text-field-ind {
   color: $md-icon-color;
 }
+
+.md-password-btn {
+  @extend %button-unstyled;
+  @extend %clickable;
+  @extend %md-hint-text;
+
+  height: $md-font-icon-size;
+  padding-left: $md-default-padding;
+  vertical-align: bottom;
+
+  &.active {
+    @extend %md-secondary-text;
+  }
+
+  .md-icon {
+    color: inherit;
+  }
+}
diff --git a/src/scss/helpers/mixins/_text-fields.scss b/src/scss/helpers/mixins/_text-fields.scss
index 4d5bd6bd39..b623cc5575 100644
--- a/src/scss/helpers/mixins/_text-fields.scss
+++ b/src/scss/helpers/mixins/_text-fields.scss
@@ -135,6 +135,10 @@
       padding-top: $ind-padding;
     }
   }
+
+  .md-password-btn {
+    margin-bottom: $md-text-field-mobile-divider-padding * 2;
+  }
 }
 
 /// Generates css for a text field on desktop
@@ -165,6 +169,10 @@
       padding-top: $ind-padding;
     }
   }
+
+  .md-password-btn {
+    margin-bottom: $md-text-field-desktop-divider-padding * 2;
+  }
 }
 
 /// Themes the text fields for the given color. This theme mixin does not use the "&." syntax.
@@ -205,12 +213,10 @@
 /// @param {Color} $error-color [$md-red-500] - The error color to use for the text field
 ///        underlines, icons, floating labels and error message text when there is an error.
 /// @group text-fields
-@mixin md-theme-text-fields($color: $md-primary-color, $hint-color: $md-secondary-text-color, $error-color: $md-red-500) {
+@mixin md-theme-text-fields($color: $md-primary-color, $error-color: $md-red-500) {
   .md-floating-label,
   .md-text-field-message,
   .md-text-field-icon {
-    color: $hint-color;
-
     &.error {
       color: $error-color;
     }
@@ -259,6 +265,7 @@
 ///   }
 ///
 /// @param {Color} $color - The placeholder color to use
+/// @group text-fields
 @mixin md-theme-placeholders($color) {
   // scss-lint:disable VendorPrefix, PseudoElement
 


Test patch:
diff --git a/src/js/TextFields/__tests__/FloatingLabel.js b/src/js/TextFields/__tests__/FloatingLabel.js
new file mode 100644
index 0000000000..0fea584c58
--- /dev/null
+++ b/src/js/TextFields/__tests__/FloatingLabel.js
@@ -0,0 +1,71 @@
+/*eslint-env jest*/
+jest.unmock('../FloatingLabel');
+
+import React from 'react';
+import { findDOMNode } from 'react-dom';
+import { renderIntoDocument } from 'react-addons-test-utils';
+
+import FloatingLabel from '../FloatingLabel';
+
+describe('FloatingLabel', () => {
+  it('automatically updates the label to include a \'*\' when required', () => {
+    let props = {
+      active: false,
+      error: false,
+      label: 'Hello World',
+      required: true,
+    };
+
+    let label = renderIntoDocument(<FloatingLabel {...props} />);
+    let labelNode = findDOMNode(label);
+
+    expect(labelNode.textContent).toBe('Hello World *');
+
+    props = Object.assign({}, props, { label: 'Hello World *'});
+    label = renderIntoDocument(<FloatingLabel {...props} />);
+    labelNode = findDOMNode(label);
+    expect(labelNode.textContent).toBe('Hello World *');
+  });
+
+  it('adds the statefull classNames to the label', () => {
+    let props = {
+      active: false,
+      error: false,
+      label: 'Hello World',
+    };
+    let label = renderIntoDocument(<FloatingLabel {...props} />);
+    let className = findDOMNode(label).className;
+
+    expect(className).toContain('md-floating-label');
+    expect(className).not.toContain('error');
+    expect(className).not.toContain('focus');
+    expect(className).not.toContain('active');
+
+    props = Object.assign({}, props, { active: true });
+    label = renderIntoDocument(<FloatingLabel {...props} />);
+    className = findDOMNode(label).className;
+
+    expect(className).toContain('md-floating-label');
+    expect(className).not.toContain('error');
+    expect(className).toContain('focus');
+    expect(className).toContain('active');
+
+    props = Object.assign({}, props, { error: true });
+    label = renderIntoDocument(<FloatingLabel {...props} />);
+    className = findDOMNode(label).className;
+
+    expect(className).toContain('md-floating-label');
+    expect(className).toContain('error');
+    expect(className).toContain('focus');
+    expect(className).toContain('active');
+
+    props = Object.assign({}, props, { active: false, value: 'Hello!', error: false });
+    label = renderIntoDocument(<FloatingLabel {...props} />);
+    className = findDOMNode(label).className;
+
+    expect(className).toContain('md-floating-label');
+    expect(className).not.toContain('error');
+    expect(className).not.toContain('focus');
+    expect(className).toContain('active');
+  });
+});
diff --git a/src/js/TextFields/__tests__/TextDivider.js b/src/js/TextFields/__tests__/TextDivider.js
new file mode 100644
index 0000000000..c2d6782afb
--- /dev/null
+++ b/src/js/TextFields/__tests__/TextDivider.js
@@ -0,0 +1,39 @@
+/*eslint-env jest*/
+jest.unmock('../TextDivider');
+
+import React from 'react';
+import { findDOMNode } from 'react-dom';
+import { renderIntoDocument } from 'react-addons-test-utils';
+
+import TextDivider from '../TextDivider';
+
+describe('TextDivider', () => {
+  it('updates the className with the stateful classNames', () => {
+    let props = {
+      lineDirection: 'left',
+      active: false,
+      error: false,
+      icon: false,
+    };
+
+    let divider = renderIntoDocument(<TextDivider {...props} />);
+    let className = findDOMNode(divider).className;
+
+    expect(className).toContain('md-text-divider');
+    expect(className).toContain('from-left');
+    expect(className).not.toContain('active');
+    expect(className).not.toContain('error');
+    expect(className).not.toContain('icon-offset');
+
+    props = Object.assign({}, props, { active: true, error: true, icon: true });
+
+    divider = renderIntoDocument(<TextDivider {...props} />);
+    className = findDOMNode(divider).className;
+
+    expect(className).toContain('md-text-divider');
+    expect(className).toContain('from-left');
+    expect(className).toContain('active');
+    expect(className).toContain('error');
+    expect(className).toContain('icon-offset');
+  });
+});
diff --git a/src/js/TextFields/__tests__/TextField.js b/src/js/TextFields/__tests__/TextField.js
index 6f023903c2..dcdcf31a27 100644
--- a/src/js/TextFields/__tests__/TextField.js
+++ b/src/js/TextFields/__tests__/TextField.js
@@ -1,21 +1,22 @@
 /*eslint-env jest*/
 jest.unmock('../TextField');
-jest.unmock('../FloatingLabel');
-jest.unmock('../TextDivider');
-jest.unmock('../TextFieldMessage');
-jest.unmock('../../FontIcons/FontIcon');
 
 import React from 'react';
 import { findDOMNode } from 'react-dom';
 import {
   Simulate,
   renderIntoDocument,
+  findRenderedComponentWithType,
+  scryRenderedComponentsWithType,
   findRenderedDOMComponentWithTag,
   findRenderedDOMComponentWithClass,
   scryRenderedDOMComponentsWithClass,
 } from 'react-addons-test-utils';
 
 import TextField from '../TextField';
+import FloatingLabel from '../FloatingLabel';
+import TextDivider from '../TextDivider';
+import TextFieldMessage from '../TextFieldMessage';
 import FontIcon from '../../FontIcons/FontIcon';
 
 describe('TextField', () => {
@@ -138,21 +139,21 @@ describe('TextField', () => {
       />
     );
 
-    let icon = findRenderedDOMComponentWithClass(textField, 'md-icon');
-    let input = findRenderedDOMComponentWithTag(textField, 'input');
-    let divider = findRenderedDOMComponentWithClass(textField, 'md-text-divider');
-    let label = findRenderedDOMComponentWithClass(textField, 'md-floating-label');
+    const input = findRenderedDOMComponentWithTag(textField, 'input');
+    let icon = findRenderedComponentWithType(textField, FontIcon);
+    let divider = findRenderedComponentWithType(textField, TextDivider);
+    let label = findRenderedComponentWithType(textField, FloatingLabel);
 
-    expect(icon.classList.contains('active')).toBe(false);
-    expect(input.classList.contains('active')).toBe(false);
-    expect(divider.classList.contains('active')).toBe(false);
-    expect(label.classList.contains('active')).toBe(false);
+    expect(input.className).not.toContain('active');
+    expect(icon.props.className).not.toContain('active');
+    expect(divider.props.active).toBe(false);
+    expect(label.props.active).toBe(false);
 
     Simulate.focus(input);
-    expect(icon.classList.contains('active')).toBe(true);
-    expect(input.classList.contains('active')).toBe(true);
-    expect(divider.classList.contains('active')).toBe(true);
-    expect(label.classList.contains('active')).toBe(true);
+    expect(input.className).toContain('active');
+    expect(icon.props.className).toContain('active');
+    expect(divider.props.active).toBe(true);
+    expect(label.props.active).toBe(true);
   });
 
   it('uses the placeholder prop as the placeholder and falls back to the label prop.', () => {
@@ -164,7 +165,7 @@ describe('TextField', () => {
     );
 
     let input = findRenderedDOMComponentWithTag(textField, 'input');
-    const floatingLabels = scryRenderedDOMComponentsWithClass(textField, 'md-floating-label');
+    const floatingLabels = scryRenderedComponentsWithType(textField, FloatingLabel);
     expect(floatingLabels.length).toBe(0);
 
     expect(input.getAttribute('placeholder')).toBe('Test');
@@ -191,71 +192,209 @@ describe('TextField', () => {
     );
 
     const input = findRenderedDOMComponentWithTag(textField, 'input');
-    const label = findRenderedDOMComponentWithClass(textField, 'md-floating-label');
+    const label = findRenderedComponentWithType(textField, FloatingLabel);
     expect(input.getAttribute('placeholder')).toBe('Placeholder');
-    expect(label.textContent).toBe('Label');
+    expect(label.props.label).toBe('Label');
   });
 
-  it('adds a counter if the maxLength prop is set to a number', () => {
-    let textField = renderIntoDocument(
-      <TextField
-        label="Test"
-        maxLength={120}
-      />
-    );
+  it('renders the FloatingLabel component with correct props', () => {
+    let props = { label: 'Hello' };
+    let textField = renderIntoDocument(<TextField {...props} />);
 
-    let message = findRenderedDOMComponentWithClass(textField, 'md-text-field-message');
-    expect(message.classList.contains('count-only')).toBe(true);
-    expect(message.textContent).toBe('0 / 120');
+    let labels = scryRenderedComponentsWithType(textField, FloatingLabel);
+    expect(labels.length).toBe(1);
 
-    textField = renderIntoDocument(
-      <TextField
-        label="Test"
-        maxLength={120}
-        value="Something something"
-      />
-    );
+    expect(labels[0].props.label).toBe(props.label);
+    expect(labels[0].props.active).toBe(false);
+    expect(labels[0].props.error).toBe(false);
+    expect(labels[0].props.required).toBeUndefined();
+    expect(labels[0].props.value).toBe('');
 
-    message = findRenderedDOMComponentWithClass(textField, 'md-text-field-message');
-    expect(message.textContent).toBe('19 / 120');
+    Simulate.focus(findRenderedDOMComponentWithTag(textField, 'input'));
 
-    const charString130 = Array.apply(null, new Array(131)).join('A');
-    expect(charString130.length).toBe(130);
-    textField = renderIntoDocument(
-      <TextField
-        label="Test"
-        value={charString130}
-        maxLength={120}
-      />
-    );
+    expect(labels[0].props.active).toBe(true);
 
-    message = findRenderedDOMComponentWithClass(textField, 'md-text-field-message');
-    expect(message.textContent).toBe('130 / 120');
+    props = Object.assign({}, props, { floatingLabel: false });
+    textField = renderIntoDocument(<TextField {...props} />);
+    labels = scryRenderedComponentsWithType(textField, FloatingLabel);
+    expect(labels.length).toBe(0);
   });
 
-  it('allows for a help text to be displayed with the text field', () => {
-    const textField = renderIntoDocument(
-      <TextField
-        label="Test"
-        helpText="Some amazing help text"
-      />
-    );
+  it('renders the TextDivider component when it is not displayed as a block', () => {
+    let textField = renderIntoDocument(<TextField block={false} />);
+
+    let dividers = scryRenderedComponentsWithType(textField, TextDivider);
+    expect(dividers.length).toBe(1);
+    expect(dividers[0].props.icon).toBe(false);
+    expect(dividers[0].props.active).toBe(false);
+    expect(dividers[0].props.lineDirection).toBe(TextField.defaultProps.lineDirection);
+
+    Simulate.focus(findRenderedDOMComponentWithTag(textField, 'input'));
 
-    const message = findRenderedDOMComponentWithClass(textField, 'md-text-field-message');
-    expect(message.textContent).toBe('Some amazing help text');
+    expect(dividers[0].props.active).toBe(true);
+
+    textField = renderIntoDocument(<TextField block={true} />);
+
+    dividers = scryRenderedComponentsWithType(textField, TextDivider);
+    expect(dividers.length).toBe(0);
   });
 
-  it('allows for help text and maxLength in the message', () => {
-    const textField = renderIntoDocument(
-      <TextField
-        label="Test"
-        helpText="Some amazing help text"
-        maxLength={120}
-      />
-    );
+  it('renders an optional icon component with additional stateful classNames', () => {
+    let props = {
+      icon: <FontIcon>wht</FontIcon>,
+      floatingLabel: true,
+    };
+
+    let textField = renderIntoDocument(<TextField {...props} />);
+    let icon = findRenderedComponentWithType(textField, FontIcon);
+    expect(icon.props.className).toContain('md-text-field-icon');
+    expect(icon.props.className).toContain('with-floating-label');
+
+    Simulate.focus(findRenderedDOMComponentWithTag(textField, 'input'));
+    expect(icon.props.className).toContain('active');
+
+    textField.setState({ value: 'something' });
+    expect(icon.props.className).toContain('normal');
+
+    props = Object.assign({}, props, { errorText: 'Some error', floatingLabel: false });
+    textField = renderIntoDocument(<TextField {...props} />);
+    icon = findRenderedComponentWithType(textField, FontIcon);
+    expect(icon.props.className).toContain('error');
+    expect(icon.props.className).not.toContain('with-floating-label');
+  });
+
+  it('renders an optional right icon with additional classNames', () => {
+    let props = {
+      rightIcon: <FontIcon>wht</FontIcon>,
+      floatingLabel: true,
+    };
+
+    let textField = renderIntoDocument(<TextField {...props} />);
+    let icon = findRenderedComponentWithType(textField, FontIcon);
+    expect(icon.props.className).toContain('md-text-field-ind');
+    expect(icon.props.className).not.toContain('single-line');
+
+    props = Object.assign({}, props, { floatingLabel: false });
+    textField = renderIntoDocument(<TextField {...props} />);
+    icon = findRenderedComponentWithType(textField, FontIcon);
+    expect(icon.props.className).toContain('md-text-field-ind');
+    expect(icon.props.className).toContain('single-line');
+  });
+
+  it('renders the TextFieldMessage component if there is a maxLength prop, an errorText prop, or helpText prop', () => {
+    let props = {
+      label: 'What',
+    };
+
+    let textField = renderIntoDocument(<TextField {...props} />);
+    let messages = scryRenderedComponentsWithType(textField, TextFieldMessage);
+    expect(messages.length).toBe(0);
+
+    props = Object.assign({}, props, { errorText: 'Some error' });
+    textField = renderIntoDocument(<TextField {...props} />);
+    messages = scryRenderedComponentsWithType(textField, TextFieldMessage);
+    expect(messages.length).toBe(1);
+
+    props = Object.assign({}, props, { errorText: null, helpText: 'What' });
+    textField = renderIntoDocument(<TextField {...props} />);
+    messages = scryRenderedComponentsWithType(textField, TextFieldMessage);
+    expect(messages.length).toBe(1);
+
+    props = Object.assign({}, props, { helpText: null, maxLength: 30 });
+    textField = renderIntoDocument(<TextField {...props} />);
+    messages = scryRenderedComponentsWithType(textField, TextFieldMessage);
+    expect(messages.length).toBe(1);
+  });
+
+  it('renders the TextFieldMessage component with the correct props for an error message', () => {
+    const props = {
+      label: 'Label',
+      errorText: 'Some error',
+      helpOnFocus: false,
+    };
+
+    const textField = renderIntoDocument(<TextField {...props} />);
+    const message = findRenderedComponentWithType(textField, TextFieldMessage);
+    expect(message.props.value).toBe('');
+    expect(message.props.error).toBe(true);
+    expect(message.props.helpOnFocus).toBe(false);
+    expect(message.props.active).toBe(false);
+    expect(message.props.message).toBe(props.errorText);
+    expect(message.props.maxLength).toBeUndefined();
+  });
+
+  it('renders the TextFieldMessage component with the correct props for a help message', () => {
+    const props = {
+      label: 'Label',
+      helpText: 'Some help',
+      helpOnFocus: false,
+    };
+
+    const textField = renderIntoDocument(<TextField {...props} />);
+    const message = findRenderedComponentWithType(textField, TextFieldMessage);
+    expect(message.props.value).toBe('');
+    expect(message.props.error).toBe(false);
+    expect(message.props.helpOnFocus).toBe(false);
+    expect(message.props.active).toBe(false);
+    expect(message.props.message).toBe(props.helpText);
+    expect(message.props.maxLength).toBeUndefined();
+  });
+
+  it('renders the TextFieldMessage component with the correct props for a counter message', () => {
+    const props = {
+      label: 'Label',
+      maxLength: 30,
+      helpOnFocus: false,
+    };
+
+    const textField = renderIntoDocument(<TextField {...props} />);
+    const message = findRenderedComponentWithType(textField, TextFieldMessage);
+    expect(message.props.value).toBe('');
+    expect(message.props.error).toBe(false);
+    expect(message.props.helpOnFocus).toBe(false);
+    expect(message.props.active).toBe(false);
+    expect(message.props.message).toBeUndefined();
+    expect(message.props.maxLength).toBe(props.maxLength);
+  });
+
+  it('if both an error and help text are given, the error message will be displayed', () => {
+    const props = {
+      label: 'Label',
+      helpText: 'Something to help',
+      errorText: 'Oh noooop',
+      helpOnFocus: false,
+    };
+
+    const textField = renderIntoDocument(<TextField {...props} />);
+    const message = findRenderedComponentWithType(textField, TextFieldMessage);
+    expect(message.props.value).toBe('');
+    expect(message.props.error).toBe(true);
+    expect(message.props.helpOnFocus).toBe(false);
+    expect(message.props.active).toBe(false);
+    expect(message.props.message).toBe(props.errorText);
+    expect(message.props.maxLength).toBeUndefined();
+  });
+
+  it('renders a password btn when the type is password', () => {
+    let textField = renderIntoDocument(<TextField type="password" />);
+    let btns = scryRenderedDOMComponentsWithClass(textField, 'md-password-btn');
+    expect(btns.length).toBe(1);
+
+    textField = renderIntoDocument(<TextField type="text" />);
+    btns = scryRenderedDOMComponentsWithClass(textField, 'md-password-btn');
+    expect(btns.length).toBe(0);
+  });
+
+  it('toggles the password\'s visible when the password button is clicked by switching the type to text', () => {
+    const textField = renderIntoDocument(<TextField type="password" />);
+    const btn = findRenderedDOMComponentWithClass(textField, 'md-password-btn');
+    const input = findRenderedDOMComponentWithTag(textField, 'input');
+
+    expect(input.getAttribute('type')).toBe('password');
+    expect(btn.className).not.toContain('active');
 
-    const message = findRenderedDOMComponentWithClass(textField, 'md-text-field-message');
-    expect(message.textContent).toBe('Some amazing help text0 / 120');
-    expect(message.classList.contains('count-only')).toBe(false);
+    Simulate.click(btn);
+    expect(input.getAttribute('type')).toBe('text');
+    expect(btn.className).toContain('active');
   });
 });
diff --git a/src/js/TextFields/__tests__/TextFieldMessage.js b/src/js/TextFields/__tests__/TextFieldMessage.js
new file mode 100644
index 0000000000..8620ab9b1a
--- /dev/null
+++ b/src/js/TextFields/__tests__/TextFieldMessage.js
@@ -0,0 +1,118 @@
+/*eslint-env jest*/
+jest.unmock('../TextFieldMessage');
+
+import React from 'react';
+import { findDOMNode } from 'react-dom';
+import {
+  renderIntoDocument,
+  findRenderedDOMComponentWithClass,
+  scryRenderedDOMComponentsWithClass,
+} from 'react-addons-test-utils';
+
+import TextFieldMessage from '../TextFieldMessage';
+
+describe('TextFieldMessage', () => {
+  it('merges the className', () => {
+    const message = renderIntoDocument(
+      <TextFieldMessage
+        className="test-class-name"
+        error={false}
+        active={false}
+        value=""
+      />
+    );
+
+    const className = findDOMNode(message).className;
+    expect(className).toContain('md-text-field-message');
+    expect(className).toContain('test-class-name');
+  });
+
+  it('renders a count when the maxLength prop is given', () => {
+    let props = {
+      maxLength: 30,
+      error: false,
+      active: false,
+      value: '',
+    };
+
+    let message = renderIntoDocument(<TextFieldMessage {...props} />);
+    let counters = scryRenderedDOMComponentsWithClass(message, 'md-text-field-counter');
+    expect(counters.length).toBe(1);
+
+    props = Object.assign({}, props, { maxLength: null });
+    message = renderIntoDocument(<TextFieldMessage {...props} />);
+    counters = scryRenderedDOMComponentsWithClass(message, 'md-text-field-counter');
+    expect(counters.length).toBe(0);
+  });
+
+  it('updates the counter based on the value\'s length', () => {
+    let props = {
+      maxLength: 30,
+      error: false,
+      active: false,
+      value: '',
+    };
+
+    let message = renderIntoDocument(<TextFieldMessage {...props} />);
+    let counter = findRenderedDOMComponentWithClass(message, 'md-text-field-counter');
+    expect(counter.textContent).toBe('0 / 30');
+
+    props = Object.assign({}, props, { value: 'Wow! I can\'t believe this is a value.' });
+
+    message = renderIntoDocument(<TextFieldMessage {...props} />);
+    counter = findRenderedDOMComponentWithClass(message, 'md-text-field-counter');
+    expect(counter.textContent).toBe(props.value.length + ' / 30');
+  });
+
+  it('renders a message', () => {
+    const props = {
+      error: false,
+      active: false,
+      value: '',
+      message: 'Lorem Ipsum',
+    };
+
+    const message = renderIntoDocument(<TextFieldMessage {...props} />);
+    const messageNode = findDOMNode(message);
+    expect(messageNode.textContent).toBe(props.message);
+  });
+
+  it('can render a message only on focus', () => {
+    let props = {
+      error: false,
+      active: false,
+      value: '',
+      message: 'Lorem Ipsum',
+      helpOnFocus: true,
+    };
+
+    let message = renderIntoDocument(<TextFieldMessage {...props} />);
+    let messageNode = findDOMNode(message);
+
+    expect(messageNode.textContent).toBe('');
+
+
+    props = Object.assign({}, props, { active: true });
+    message = renderIntoDocument(<TextFieldMessage {...props} />);
+    messageNode = findDOMNode(message);
+
+    expect(messageNode.textContent).toBe(props.message);
+  });
+
+  it('can render help text and a counter', () => {
+    const props = {
+      error: false,
+      active: false,
+      value: '',
+      message: 'Lorem Ipsum',
+      maxLength: 30,
+    };
+
+    const message = renderIntoDocument(<TextFieldMessage {...props} />);
+    const nodes = findDOMNode(message).childNodes;
+    expect(nodes.length).toBe(2);
+    expect(nodes[0].textContent).toBe(props.message);
+
+    expect(nodes[1].textContent).toBe('0 / 30');
+  });
+});