Repository: mlaursen/react-md
Pull request #94 (2016-06-01 17:04:32)

Feature/bottom navigations
This completes #8 


Patch:
diff --git a/src/js/BottomNavigations/BottomNav.js b/src/js/BottomNavigations/BottomNav.js
new file mode 100644
index 0000000000..7a829d8dbf
--- /dev/null
+++ b/src/js/BottomNavigations/BottomNav.js
@@ -0,0 +1,147 @@
+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';
+import FontIcon from '../FontIcons';
+import injectInk from '../Inks';
+
+/**
+ * The `BottomNav` component is used for rendering one of the nav items
+ * in the `BottomNavigation` component. This is used for switching the view.
+ */
+class BottomNav 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,
+
+    /**
+     * The label for the navigation.
+     */
+    label: PropTypes.string.isRequired,
+
+    /**
+     * Any children used to display the icon.
+     */
+    iconChildren: PropTypes.node,
+
+    /**
+     * The icon className to use.
+     */
+    iconClassName: PropTypes.string,
+
+    /**
+     * Boolean if this nav is currently active.
+     */
+    active: PropTypes.bool.isRequired,
+
+    /**
+     * Boolean if the nav is colored.
+     */
+    colored: PropTypes.bool,
+
+    /**
+     * An optional function to call onClick. It is called
+     * with the label and click event.
+     *
+     * `onClick(label, event)`
+     */
+    onClick: PropTypes.func,
+
+    /**
+     * A function injected from the `BottomNavigation` component.
+     * It is called with the label and click event.
+     *
+     * `onClick(label, event)`
+     */
+    onNavChange: PropTypes.func.isRequired,
+
+    /**
+     * Boolean if the tabs are fixed sizes. This will be true
+     * if there are only three BottomNavs.
+     */
+    fixed: PropTypes.bool.isRequired,
+
+    /**
+     * Ink injected from `injectInk`.
+     */
+    ink: PropTypes.node,
+
+    /**
+     * The component to render as.
+     */
+    component: PropTypes.oneOfType([
+      PropTypes.string,
+      PropTypes.func,
+    ]).isRequired,
+  };
+
+  static defaultProps = {
+    component: 'button',
+  };
+
+  handleClick = (e) => {
+    const { onClick, onNavChange, label } = this.props;
+    onClick && onClick(label, e);
+    onNavChange(label, e);
+  };
+
+  render() {
+    const {
+      className,
+      label,
+      iconClassName,
+      iconChildren,
+      colored,
+      active,
+      fixed,
+      ink,
+      component,
+      ...props,
+    } = this.props;
+    delete props.onClick;
+
+
+    let displayLabel;
+    if(fixed || active) {
+      displayLabel = <span key="label">{label}</span>;
+    }
+
+    return (
+      <CSSTransitionGroup
+        type={component === 'button' ? 'button' : null}
+        component={component}
+        transitionName="bottom-nav"
+        transitionEnterTimeout={150}
+        transitionLeave={false}
+        className={classnames('md-bottom-nav', className, {
+          active,
+          colored,
+          fixed,
+          'shifting': !fixed,
+          'default': !colored,
+        })}
+        {...props}
+        onClick={this.handleClick}
+      >
+        {ink}
+        <FontIcon key="icon" iconClassName={iconClassName}>{iconChildren}</FontIcon>
+        {displayLabel}
+      </CSSTransitionGroup>
+    );
+  }
+}
+
+export default injectInk(BottomNav);
diff --git a/src/js/BottomNavigations/BottomNavigation.js b/src/js/BottomNavigations/BottomNavigation.js
new file mode 100644
index 0000000000..93b0e71c24
--- /dev/null
+++ b/src/js/BottomNavigations/BottomNavigation.js
@@ -0,0 +1,259 @@
+import React, { Component, PropTypes } from 'react';
+import CSSTransitionGroup from 'react-addons-css-transition-group';
+import PureRenderMixin from 'react-addons-pure-render-mixin';
+import classnames from 'classnames';
+
+import { isBetween } from '../utils';
+import BottomNav from './BottomNav';
+
+/**
+ * The `BottomNavigation` component is used when there are three to five
+ * top-level destinations that require direct access on mobile devices.
+ */
+export default class BottomNavigation extends Component {
+  constructor(props) {
+    super(props);
+
+    this.shouldComponentUpdate = PureRenderMixin.shouldComponentUpdate.bind(this);
+    this.state = {
+      active: props.initiallyActive || props.actions[0].label,
+      visible: props.initiallyVisible,
+      pageY: null,
+      scrolling: false,
+    };
+  }
+
+  static propTypes = {
+    /**
+     * An optional style to apply.
+     */
+    style: PropTypes.object,
+
+    /**
+     * An optional className to apply.
+     */
+    className: PropTypes.string,
+
+    /**
+     * An optional style to apply to the `CSSTransitionGroup` container.
+     */
+    containerStyle: PropTypes.object,
+
+    /**
+     * An optional className to apply to the `CSSTransitionGroup` container.
+     * The container will always contain a className of `md-bottom-navigation-container`.
+     */
+    containerClassName: PropTypes.string,
+
+    /**
+     * The list of navigation actions to use. The custom validation throws warnings
+     * if there are less than 3 or more than 5 actions. An action is the following shape:
+     *
+     * ```js
+     * action: PropTypes.shape({
+     *   label: PropTypes.string.isRequired,
+     *   iconClassName: PropTypes.string,
+     *   iconChildren: PropTypes.node,
+     *   onClick: PropTypes.func,
+     *   component: PropTypes.oneOfType([
+     *     PropTypes.string,
+     *     PropTypes.func,
+     *   ]).isRequired,
+     *   ...componentProps,
+     * }),
+     * ```
+     *
+     * The default component is a 'button'.
+     */
+    actions: (props, propName, component) => {
+      const err = PropTypes.arrayOf(PropTypes.shape({
+        label: PropTypes.string.isRequired,
+        iconClassName: PropTypes.string,
+        iconChildren: PropTypes.node,
+      })).isRequired(props, propName, component);
+
+      if(err) {
+        return err;
+      } else if(!isBetween(props[propName].length, 3, 5)) {
+        return new Error(`The '${component}' expects a number of actions between 3 and 5 but '${props[propName].length}' were given`);
+      }
+    },
+
+    /**
+     * Boolean if the navigation actions should be colored.
+     */
+    colored: PropTypes.bool,
+
+    /**
+     * An optional label to use for an action that is currently active. If
+     * omitted, the first action's label will be used.
+     */
+    initiallyActive: PropTypes.string,
+
+    /**
+     * Boolean if the bottom navigation component is initially visible.
+     */
+    initiallyVisible: PropTypes.bool.isRequired,
+
+    /**
+     * The transition name to use for the `BottomNavigation` appearing/disappearing
+     * when dynamic.
+     */
+    transitionName: PropTypes.string.isRequired,
+
+    /**
+     * The transition enter timeout to use for the `BottomNavigation` appearing/disappearing
+     * when dynamic.
+     */
+    transitionEnterTimeout: PropTypes.number.isRequired,
+
+    /**
+     * The transition leave timeout to use for the `BottomNavigation` appearing/disappearing
+     * when dynamic.
+     */
+    transitionLeaveTimeout: PropTypes.number.isRequired,
+
+    /**
+     * Boolean if the `BottomNavigation` is dynamic. This means that when the user scrolls
+     * downwards, the component will be hidden. When the user scrolls upwards, the component
+     * will be visible again.
+     */
+    dynamic: PropTypes.bool.isRequired,
+
+    /**
+     * An optional function to call when the active action is changed. This
+     * function is given the clicked action's label.
+     */
+    onChange: PropTypes.func,
+  };
+
+  static defaultProps = {
+    transitionName: 'bottom-navigation',
+    transitionEnterTimeout: 150,
+    transitionLeaveTimeout: 150,
+    dynamic: true,
+    initiallyVisible: true,
+  };
+
+  componentDidMount() {
+    if(this.props.dynamic) {
+      this.addTouchEvents();
+    }
+  }
+
+  componentWillReceiveProps(nextProps) {
+    if(this.props.dynamic === nextProps.dynamic) { return; }
+    if(nextProps.dynamic) {
+      this.addTouchEvents();
+    } else {
+      this.removeTouchEvents();
+    }
+  }
+
+  componentWillUnmount() {
+    if(this.props.dynamic) {
+      this.removeTouchEvents();
+    }
+  }
+
+  addTouchEvents = () => {
+    window.addEventListener('touchstart', this.handleTouchStart);
+    window.addEventListener('touchmove', this.handleTouchMove);
+    window.addEventListener('touchend', this.handleTouchEnd);
+  };
+
+  removeTouchEvents = () => {
+    window.removeEventListener('touchstart', this.handleTouchStart);
+    window.removeEventListener('touchmove', this.handleTouchMove);
+    window.removeEventListener('touchend', this.handleTouchEnd);
+  };
+
+  handleTouchStart = (e) => {
+    const { pageY } = e.changedTouches[0];
+
+    this.setState({ pageY, scrolling: true });
+  };
+
+  handleTouchMove = (e) => {
+    const { scrolling, visible, pageY } = this.state;
+    if(!scrolling) { return; }
+
+    const touchY = e.changedTouches[0].pageY;
+    if((visible && pageY > touchY) || (!visible && pageY < touchY)) {
+      this.setState({ visible: !visible, pageY: touchY });
+    }
+  };
+
+  handleTouchEnd = () => {
+    if(!this.state.scrolling) { return; }
+    this.setState({ pageY: null, scrolling: false });
+  };
+
+  getActive = (props = this.props, state = this.state) => {
+    return typeof props.active !== 'undefined' ? props.active : state.active;
+  };
+
+  handleNavChange = (label) => {
+    const active = this.getActive();
+    if(active === label) { return; }
+    this.props.onChange && this.props.onChange(label);
+
+    this.setState({ active: label });
+  };
+
+  render() {
+    const { active, visible } = this.state;
+    const {
+      style,
+      className,
+      containerStyle,
+      containerClassName,
+      colored,
+      actions,
+      transitionName,
+      transitionEnterTimeout,
+      transitionLeaveTimeout,
+    } = this.props;
+
+    const fixed = actions.length === 3;
+    const navs = actions.map(({ label, ...props }) => (
+      <BottomNav
+        key={label}
+        {...props}
+        onNavChange={this.handleNavChange}
+        label={label}
+        active={active === label}
+        colored={colored}
+        fixed={fixed}
+      />
+    ));
+
+    let nav;
+    if(visible) {
+      nav = (
+        <footer
+          key="nav"
+          style={style}
+          className={classnames('md-bottom-navigation', className, {
+            colored,
+            'default': !colored,
+          })}
+        >
+          {navs}
+        </footer>
+      );
+    }
+
+    return (
+      <CSSTransitionGroup
+        style={containerStyle}
+        className={classnames('md-bottom-navigation-container', containerClassName)}
+        transitionName={transitionName}
+        transitionEnterTimeout={transitionEnterTimeout}
+        transitionLeaveTimeout={transitionLeaveTimeout}
+      >
+        {nav}
+      </CSSTransitionGroup>
+    );
+  }
+}
diff --git a/src/js/BottomNavigations/index.js b/src/js/BottomNavigations/index.js
new file mode 100644
index 0000000000..8fa5771d73
--- /dev/null
+++ b/src/js/BottomNavigations/index.js
@@ -0,0 +1,2 @@
+import BottomNavigation from './BottomNavigation';
+export default BottomNavigation;
diff --git a/src/scss/components/_bottom-navigations.scss b/src/scss/components/_bottom-navigations.scss
new file mode 100644
index 0000000000..6193dcfea3
--- /dev/null
+++ b/src/scss/components/_bottom-navigations.scss
@@ -0,0 +1,76 @@
+.md-bottom-navigation {
+  @include md-box-shadow(1);
+
+  bottom: 0;
+  display: flex;
+  left: 0;
+  position: fixed;
+  width: 100%;
+}
+
+.md-bottom-nav {
+  @extend %button-unstyled;
+  @extend %clickable;
+
+  flex-grow: 1;
+  font-size: $md-bottom-nav-inactive-font-size;
+  height: $md-bottom-navigation-height;
+  padding: $md-bottom-nav-padding;
+  transition-duration: $md-transition-time;
+  transition-property: color, font-size;
+  user-select: none;
+
+  &.active {
+    flex-shrink: 0;
+    font-size: $md-bottom-nav-active-font-size;
+  }
+
+  &.fixed {
+    max-width: $md-bottom-nav-fixed-max-width;
+    min-width: $md-bottom-nav-fixed-min-width;
+  }
+
+  &.shifting {
+    max-width: $md-bottom-nav-shifting-inactive-max-width;
+    min-width: $md-bottom-nav-shifting-inactive-min-width;
+    transition-property: color, font-size, max-width;
+
+    &.active {
+      max-width: $md-bottom-nav-fixed-max-width;
+      min-width: $md-bottom-nav-shifting-active-min-width;
+    }
+  }
+
+  .md-icon {
+    color: inherit;
+    display: block;
+  }
+}
+
+.bottom-nav-enter {
+  max-height: 0;
+  overflow: hidden;
+
+  &.bottom-nav-enter-active {
+    max-height: 16px;
+    transition: max-height $md-bottom-nav-transition-time;
+  }
+}
+
+.bottom-navigation-enter {
+  max-height: 0;
+
+  &.bottom-navigation-enter-active {
+    max-height: $md-bottom-navigation-height;
+    transition: max-height $md-bottom-navigation-transition-time;
+  }
+}
+
+.bottom-navigation-leave {
+  max-height: $md-bottom-navigation-height;
+
+  &.bottom-navigation-leave-active {
+    max-height: 0;
+    transition: max-height $md-bottom-navigation-transition-time;
+  }
+}
diff --git a/src/scss/helpers/_mixins.scss b/src/scss/helpers/_mixins.scss
index 39070e2cc7..99c67104eb 100644
--- a/src/scss/helpers/_mixins.scss
+++ b/src/scss/helpers/_mixins.scss
@@ -1,4 +1,5 @@
 @import 'mixins/avatars';
+@import 'mixins/bottom-navigations';
 @import 'mixins/buttons';
 @import 'mixins/cards';
 @import 'mixins/fonts';
diff --git a/src/scss/helpers/_variables.scss b/src/scss/helpers/_variables.scss
index 24fda92271..280a43f78c 100644
--- a/src/scss/helpers/_variables.scss
+++ b/src/scss/helpers/_variables.scss
@@ -126,25 +126,25 @@ $md-transition-time: .15s !default;
 /// The default z-index for any `.md-overlay` or any overlay in the app.
 ///
 /// @type Number
-/// @group z-indexes, overlay
+/// @group overlays
 $md-overlay-z-index: 100 !default;
 
 /// The default z-index for the Data table's edit dialog.
 ///
 /// @type Number
-/// @group z-indexes, data-tables
+/// @group data-tables
 $md-edit-dialog-z-index: 3 !default;
 
 /// The default z-index for the Dropdown component.
 ///
 /// @type Number
-/// @group z-indexes, dropdowns
+/// @group dropdowns
 $md-dropdown-z-index: 4 !default;
 
 /// The default z-index for the sidebar that is not fixed.
 ///
 /// @type Number
-/// @group z-indexes, sidebars
+/// @group sidebars
 $md-sidebar-z-index: 5 !default;
 
 /// The default z-index for the sidebar that is fixed to the side.
@@ -152,7 +152,7 @@ $md-sidebar-z-index: 5 !default;
 /// for the dialog z-index
 ///
 /// @type Number
-/// @group z-indexes, sidebars
+/// @group sidebars
 /// @see md-sidebar-z-index
 /// @see md-dialog-z-index
 $md-sidebar-fixed-z-index: 10 !default;
@@ -160,44 +160,44 @@ $md-sidebar-fixed-z-index: 10 !default;
 /// The default z-index for a fixed toolbar.
 ///
 /// @type Number
-/// @group z-indexes, toolbars
+/// @group toolbars
 $md-toolbar-z-index: 6 !default;
 
 /// The default z-index for the fixed floating action button.
 ///
 /// @type Number
-/// @group z-indexes, buttons-fab
+/// @group buttons-fab
 $md-fab-z-index: 8 !default;
 
 /// The default z-index for the navigation-drawer
 ///
 /// @type Number
-/// @group z-indexes, navigation-drawers
+/// @group navigation-drawers
 $md-navigation-drawer-z-index: $md-overlay-z-index + 1 !default;
 
 /// The default z-index for the snackbar component.
 ///
 /// @type Number
-/// @group z-indexesm snackbars
+/// @group snackbars
 $md-snackbar-z-index: 11 !default;
 
 /// The default z-index for the temporary navigation-drawer
 ///
 /// @type Number
-/// @group z-indexes, navigation-drawers
+/// @group navigation-drawers
 $md-navigation-drawer-temporary-z-index: $md-overlay-z-index + 1 !default;
 
 /// The default z-index for the temporary navigation-drawer
 ///
 /// @type Number
-/// @group z-indexes, navigation-drawers
+/// @group navigation-drawers
 $md-navigation-drawer-toolbar-z-index: $md-overlay-z-index - 1 !default;
 
 
 /// The default z-index for the dialog component.
 ///
 /// @type Number
-/// @group z-indexes, dialogs
+/// @group dialogs
 $md-dialog-z-index: $md-overlay-z-index + 5 !default;
 
 
@@ -757,25 +757,25 @@ $md-switch-width: $md-switch-height + $md-switch-bar-height - $md-switch-offset
 /// The toolbar height.
 ///
 /// @type String
-/// @group toolbar
+/// @group toolbars
 $md-toolbar-height: 64px !default;
 
 /// The toolbar height for landscape mode on mobile devices.
 ///
 /// @type String
-/// @group toolbar
+/// @group toolbars
 $md-toolbar-mobile-landscape-height: 48px;
 
 /// The toolbar height for landscape mode on mobile devices.
 ///
 /// @type String
-/// @group toolbar
+/// @group toolbars
 $md-toolbar-mobile-portrait-height: 56px;
 
 /// The toolbar height for landscape mode on mobile devices.
 ///
 /// @type String
-/// @group toolbar
+/// @group toolbars
 $md-toolbar-desktop-height: 64px;
 
 /// The toolbar margin for any 'actions' in a toolbar. This
@@ -783,7 +783,7 @@ $md-toolbar-desktop-height: 64px;
 /// `.action-right`.
 ///
 /// @type String
-/// @group toolbar
+/// @group toolbars
 $md-toolbar-action-margin: 12px;
 
 
@@ -791,7 +791,7 @@ $md-toolbar-action-margin: 12px;
 /// to the left of the title.
 ///
 /// @type String
-/// @group toolbar
+/// @group toolbars
 $md-toolbar-title-keyline: 72px;
 
 
@@ -828,73 +828,73 @@ $md-sidebar-desktop-max-width: 400px;
 ///
 /// @link https://www.google.com/design/spec/components/snackbars-toasts.html#snackbars-toasts-specs
 /// @type String
-/// @group snackbar
+/// @group snackbars
 $md-snackbar-bg-color: #323232;
 
 /// The snackbar height.
 ///
 /// @type String
-/// @group snackbar
+/// @group snackbars
 $md-snackbar-height: $md-height;
 
 /// The height for a snackbar that is multiple lines.
 ///
 /// @type String
-/// @group snackbar
+/// @group snackbars
 $md-snackbar-multiline-height: 80px;
 
 /// The left and right padding for the snackbar.
 ///
 /// @type String
-/// @group snackbar
+/// @group snackbars
 $md-snackbar-lr-padding: 24px;
 
 /// The top and bottom badding for the snackbar.
 ///
 /// @type String
-/// @group snackbar
+/// @group snackbars
 $md-snackbar-tb-padding: 14px;
 
 /// The margin left for the action button in a sidebar.
 ///
 /// @type String
-/// @group snackbar
+/// @group snackbars
 $md-snackbar-action-left-margin: 48px;
 
 /// The min width for a snackbar on a desktop screen.
 ///
 /// @type String
-/// @group snackbar
+/// @group snackbars
 $md-snackbar-desktop-min-width: 288px;
 
 /// The max width for a snackbar on a desktop screen.
 ///
 /// @type String
-/// @group snackbar
+/// @group snackbars
 $md-snackbar-desktop-max-width: 568px;
 
 /// The border radius for a snackbar on a desktop screen.
 ///
 /// @type String
-/// @group snackbar
+/// @group snackbars
 $md-snackbar-desktop-border-radius: 2px;
 
 /// The transition time for the snackbar appearing.
 ///
 /// @type Time
-/// @group snackbar
+/// @group snackbars
 $md-snackbar-transition-time: $md-transition-time * 2 !default;
 
 /// The transition time for the snackbar's children appearing.
 ///
 /// @type Time
-/// @group snackbar
+/// @group snackbars
 $md-snackbar-opacity-transition-time: $md-transition-time * 3 !default;
 
 /// The transition delay for chained toasts.
 ///
 /// @type Time
-/// @group snackbar
+/// @group snackbars
 $md-snackbar-chained-transition-delay: $md-transition-time * 3 !default;
 
 
@@ -1155,7 +1155,7 @@ $md-dropdown-max-height: $md-height * 5 + $md-list-tile-extra-margin * 2 + $md-h
 /// The distance from the element for a mobile device.
 ///
 /// @type String
-/// @group select-feilds
+/// @group select-fields
 $md-select-field-mobile-top: 8px;
 
 /// The distance from the element for a desktop device.
@@ -1682,3 +1682,80 @@ $md-edit-dialog-min-width: 300px !default;
 /// @type String
 /// @group data-tables
 $md-data-table-sort-icon-font-size: 16px;
+
+
+// ===================================================================================
+// Bottom Navigations
+// ===================================================================================
+
+/// The height for the bottom navigation component.
+///
+/// @type String
+/// @group bottom-navigations
+$md-bottom-navigation-height: 56px;
+
+/// The min width for a bottom-nav action that is fixed. A fixed bottom-nav
+/// is only available when there are only 3 actions.
+///
+/// @type String
+/// @group bottom-navigations
+$md-bottom-nav-fixed-min-width: 80px;
+
+/// THe max width for a bottom-nav action that is fixed. A fixed bottom-nav
+/// is only available when there are only 3 actions.
+///
+/// @type String
+/// @group bottom-navigations
+$md-bottom-nav-fixed-max-width: 168px;
+
+/// The min width for an active bottom-nav action when the bottom-nav size is
+/// shifting. A shifting bottom-nav is used when there are more than 3 actions.
+///
+/// @type String
+/// @group bottom-navigations
+$md-bottom-nav-shifting-active-min-width: 96px;
+
+/// The min width for an inactive bottom-nav action when the bottom-nav size is
+/// shifting. A shifting bottom-nav is used when there are more than 3 actions.
+///
+/// @type String
+/// @group bottom-navigations
+$md-bottom-nav-shifting-inactive-min-width: 56px;
+
+/// The max width for an inactive bottom-nav action when the bottom-nav size is
+/// shifting. A shifting bottom-nav is used when there are more than 3 actions.
+///
+/// @type String
+/// @group bottom-navigations
+$md-bottom-nav-shifting-inactive-max-width: 96px;
+
+/// The padding to use for a bottom-nav action.
+///
+/// @type String
+/// @group bottom-navigations
+$md-bottom-nav-padding: 6px 12px 10px;
+
+/// The font size to use for an active bottom-nav action.
+///
+/// @type String
+/// @group bottom-navigations
+$md-bottom-nav-active-font-size: 14px;
+
+/// The font size to use for an inactive bottom-nav action.
+///
+/// @type String
+/// @group bottom-navigations
+$md-bottom-nav-inactive-font-size: 12px;
+
+/// The transition time for the label appearing in a shifting bottom-nav action.
+///
+/// @type Time
+/// @group bottom-navigations
+$md-bottom-nav-transition-time: $md-transition-time !default;
+
+/// The transition time for the entire bottom-navigation component appearing when
+/// a user has scrolled the bottom-navigation into or out of view.
+///
+/// @type Time
+/// @group bottom-navigations
+$md-bottom-navigation-transition-time: $md-transition-time !default;
diff --git a/src/scss/helpers/mixins/_bottom-navigations.scss b/src/scss/helpers/mixins/_bottom-navigations.scss
new file mode 100644
index 0000000000..77b5d82b64
--- /dev/null
+++ b/src/scss/helpers/mixins/_bottom-navigations.scss
@@ -0,0 +1,86 @@
+/// Themes the bottom navigation component for light or dark themes when it is un-colored.
+/// The background color will either be the `$md-light-theme-card-color` or the
+/// `$md-dark-theme-card-color` depending on the `$light-theme` param.
+///
+/// @example scss - Example Basic Usage SASS
+///   @include md-theme-bottom-navigations-default($md-red-500);
+///
+/// @example scss - Example Basic Usage CSS Output
+///   .md-bottom-navigation.default {
+///     background: #fff;
+///   }
+///
+///   .md-bottom-nav.default {
+///     color: rgba(0, 0, 0, .87);
+///   }
+///
+///   .md-bottom-nav.default.active {
+///     color: #f44336;
+///   }
+///
+/// @param {Color} $primary [$md-primary-color] - The primary color to style with.
+/// @param {Bool} $light-theme [$md-light-theme] - Boolean if it should be themed for the light-theme.
+/// @group bottom-navigations
+@mixin md-theme-bottom-navigations-default($primary: $md-primary-color, $light-theme: $md-light-theme) {
+  .md-bottom-navigation.default {
+    background: if($light-theme, $md-light-theme-card-color, $md-dark-theme-card-color);
+  }
+
+  .md-bottom-nav.default {
+    color: if($light-theme, $md-light-theme-text-color, $md-dark-theme-text-color);
+
+    &.active {
+      color: $primary;
+    }
+  }
+}
+
+/// Themes the bottom navigation's background color with the given colors.
+///
+/// @example scss - Example Basic Usage SASS
+///   @include md-theme-bottom-navigations-colored($md-red-500, $md-white-base, rgba($md-red-500, .3));
+///
+/// @example scss - Example Basic Usage CSS Output
+///   .md-bottom-navigation.colored {
+///     background: #f44336;
+///   }
+///
+///   .md-bottom-nav.colored {
+///     color: #fff;
+///   }
+///
+///   .md-bottom-nav.colored .md-ink {
+///     color: rgba(#f44336, .3);
+///   }
+///
+/// @param {Color} $bg-color [$md-primary-color] - The background color to use.
+/// @param {Color} $text-color [$md-white-base] - The inactive text color to use.
+/// @param {Color} $ink-color [rgba($md-white-base, .2)] - The ink color to use for the bottom nav.
+/// @group bottom-navigations
+@mixin md-theme-bottom-navigations-colored($bg-color: $md-primary-color, $text-color: $md-white-base, $ink-color: rgba($md-white-base, .2)) {
+  .md-bottom-navigation.colored {
+    background: $bg-color;
+  }
+
+  .md-bottom-nav.colored {
+    color: $text-color;
+
+    .md-ink {
+      background: $ink-color;
+    }
+  }
+}
+
+/// Themes both the default and the colored versions of the Bottom Navigation component.
+///
+/// @see md-theme-bottom-navigations-default
+/// @see md-theme-bottom-navigations-colored
+/// @param {Color} $primary [$md-primary-color] - The primary color to theme with.
+/// @param {Color} $text-color [$md-white-base] - The text color to use for the colored bottom navigations.
+/// @param {Color} $ink-color [rgba($md-white-base, .2)] - The ink color to use for colored bottom navigations.
+/// @param {Bool} $light-theme [$md-light-theme] - Boolean if the default bottom navigation should be styled to the light theme.
+/// @group bottom-navigations
+@mixin md-theme-bottom-navigations($primary: $md-primary-color, $text-color: $md-white-base, $ink-color: rgba($md-white-base, .2), $light-theme: $md-light-theme) {
+  @include md-theme-bottom-navigations-default($primary, $light-theme);
+  @include md-theme-bottom-navigations-colored($primary, $text-color, $ink-color);
+}
diff --git a/src/scss/helpers/mixins/_theme.scss b/src/scss/helpers/mixins/_theme.scss
index 38ebd33657..b3c3bb1be5 100644
--- a/src/scss/helpers/mixins/_theme.scss
+++ b/src/scss/helpers/mixins/_theme.scss
@@ -9,6 +9,7 @@
 ///       for the progress bar at an inactive state.
 /// @group colors
 @mixin md-theme-most($primary: $md-primary-color, $primary-hue-1: $md-primary-color-hue-1, $secondary: $md-secondary-color, $secondary-hue-1: $md-secondary-color-hue-1) {
+  @include md-theme-bottom-navigations($primary);
   @include md-theme-navigation-drawers($primary);
   @include md-theme-text-fields($primary);
   @include md-theme-snackbars($primary);
diff --git a/src/scss/react-md.scss b/src/scss/react-md.scss
index b2353ae5ce..ef11db1c64 100644
--- a/src/scss/react-md.scss
+++ b/src/scss/react-md.scss
@@ -5,6 +5,7 @@
 @import 'transitions';
 
 @import 'components/avatars';
+@import 'components/bottom-navigations';
 @import 'components/buttons';
 @import 'components/cards';
 @import 'components/chips';


Test patch:
diff --git a/src/js/BottomNavigations/__tests__/BottomNav.js b/src/js/BottomNavigations/__tests__/BottomNav.js
new file mode 100644
index 0000000000..3fbcdb8603
--- /dev/null
+++ b/src/js/BottomNavigations/__tests__/BottomNav.js
@@ -0,0 +1,169 @@
+/*eslint-env jest*/
+jest.unmock('../BottomNav');
+
+import React from 'react';
+import { findDOMNode } from 'react-dom';
+import {
+  Simulate,
+  renderIntoDocument,
+  scryRenderedComponentsWithType,
+  scryRenderedDOMComponentsWithTag,
+} from 'react-addons-test-utils';
+
+import BottomNav from '../BottomNav';
+
+describe('BottomNav', () => {
+  it('merges className and style', () => {
+    const style = { display: 'block' };
+    const className = 'test';
+    const bottomNav = renderIntoDocument(
+      <BottomNav
+        style={style}
+        className={className}
+        label="A"
+        active={false}
+        onNavChange={jest.fn()}
+        fixed={false}
+      />
+    );
+
+    const bottomNavNode = findDOMNode(bottomNav);
+    expect(bottomNavNode.style.display).toBe(style.display);
+    expect(bottomNavNode.classList.contains(className)).toBe(true);
+  });
+
+  it('passes the label and click event to the onClick and onNavChange props', () => {
+    const props = {
+      onNavChange: jest.fn(),
+      onClick: jest.fn(),
+      label: 'a',
+      fixed: false,
+      active: false,
+    };
+
+    const nav = renderIntoDocument(<BottomNav {...props} />);
+    const navNode = findDOMNode(nav);
+
+    Simulate.click(navNode);
+
+    expect(props.onNavChange.mock.calls.length).toBe(1);
+    expect(props.onNavChange.mock.calls[0][0]).toBe(props.label);
+
+    expect(props.onClick.mock.calls.length).toBe(1);
+    expect(props.onClick.mock.calls[0][0]).toBe(props.label);
+  });
+
+  it('renders as a button by default', () => {
+    const nav = renderIntoDocument(<BottomNav label="A" active={false} fixed={false} onNavChange={jest.fn()} />);
+    const btns = scryRenderedDOMComponentsWithTag(nav, 'button');
+    expect(btns.length).toBe(1);
+  });
+
+  it('renders as the component prop', () => {
+    /*eslint-disable react/prop-types*/
+    class Link extends React.Component {
+      render() {
+        const { to, ...props } = this.props;
+        return <a href={to} {...props} />;
+      }
+    }
+
+    const props = {
+      label: 'a',
+      to: '/test',
+      active: false,
+      fixed: false,
+      component: Link,
+      onNavChange: jest.fn(),
+    };
+
+    const nav = renderIntoDocument(<BottomNav {...props} />);
+    const links = scryRenderedComponentsWithType(nav, Link);
+    expect(links.length).toBe(1);
+  });
+
+  it('passes alll event listener props to the component', () => {
+    const onClick = jest.fn();
+    const onFocus = jest.fn();
+    const onBlur = jest.fn();
+    const onMouseDown = jest.fn();
+    const onMouseUp = jest.fn();
+    const onMouseOver = jest.fn();
+    const onMouseLeave = jest.fn();
+    const onTouchStart = jest.fn();
+    const onTouchEnd = jest.fn();
+    const onTouchCancel = jest.fn();
+    const props = {
+      onClick,
+      onFocus,
+      onBlur,
+      onMouseDown,
+      onMouseUp,
+      onMouseOver,
+      onMouseLeave,
+      onTouchStart,
+      onTouchEnd,
+      onTouchCancel,
+      active: false,
+      fixed: false,
+      label: 'A',
+      onNavChange: jest.fn(),
+    };
+
+    const nav = renderIntoDocument(<BottomNav {...props} />);
+    const navNode = findDOMNode(nav);
+
+    Simulate.click(navNode);
+    expect(onClick.mock.calls.length).toBe(1);
+
+    Simulate.focus(navNode);
+    expect(onFocus.mock.calls.length).toBe(1);
+
+    Simulate.blur(navNode);
+    expect(onBlur.mock.calls.length).toBe(1);
+
+    Simulate.mouseDown(navNode);
+    expect(onMouseDown.mock.calls.length).toBe(1);
+
+    Simulate.mouseUp(navNode);
+    expect(onMouseUp.mock.calls.length).toBe(1);
+
+    Simulate.mouseOver(navNode);
+    expect(onMouseOver.mock.calls.length).toBe(1);
+
+    Simulate.mouseLeave(navNode);
+    expect(onMouseLeave.mock.calls.length).toBe(1);
+
+    Simulate.touchStart(navNode);
+    expect(onTouchStart.mock.calls.length).toBe(1);
+
+    Simulate.touchEnd(navNode);
+    expect(onTouchEnd.mock.calls.length).toBe(1);
+
+    Simulate.touchCancel(navNode);
+    expect(onTouchCancel.mock.calls.length).toBe(1);
+  });
+
+  it('renders the label prop when fixed or active', () => {
+    const props = {
+      label: 'Hello, World!',
+      onNavChange: jest.fn(),
+    };
+
+    let nav = renderIntoDocument(<BottomNav {...props} active={false} fixed={false} />);
+    let navNode = findDOMNode(nav);
+    expect(navNode.textContent).toBe('');
+
+    nav = renderIntoDocument(<BottomNav {...props} active={true} fixed={false} />);
+    navNode = findDOMNode(nav);
+    expect(navNode.textContent).toBe(props.label);
+
+    nav = renderIntoDocument(<BottomNav {...props} active={true} fixed={true} />);
+    navNode = findDOMNode(nav);
+    expect(navNode.textContent).toBe(props.label);
+
+    nav = renderIntoDocument(<BottomNav {...props} active={false} fixed={true} />);
+    navNode = findDOMNode(nav);
+    expect(navNode.textContent).toBe(props.label);
+  });
+});
diff --git a/src/js/BottomNavigations/__tests__/BottomNavigation.js b/src/js/BottomNavigations/__tests__/BottomNavigation.js
new file mode 100644
index 0000000000..b56493a847
--- /dev/null
+++ b/src/js/BottomNavigations/__tests__/BottomNavigation.js
@@ -0,0 +1,141 @@
+/*eslint-env jest*/
+jest.unmock('../BottomNavigation');
+
+import React from 'react';
+import { findDOMNode } from 'react-dom';
+import {
+  renderIntoDocument,
+  scryRenderedComponentsWithType,
+  scryRenderedDOMComponentsWithTag,
+  findRenderedDOMComponentWithClass,
+} from 'react-addons-test-utils';
+
+import BottomNavigation from '../BottomNavigation';
+import BottomNav from '../BottomNav';
+
+describe('BottomNavigation', () => {
+  it('merges className and style', () => {
+    const style = { display: 'block' };
+    const className = 'test';
+    const containerStyle = { background: 'red' };
+    const containerClassName = 'test-container';
+    const nav = renderIntoDocument(
+      <BottomNavigation
+        style={style}
+        className={className}
+        containerStyle={containerStyle}
+        containerClassName={containerClassName}
+        actions={[{ label: 'a' }, { label: 'b' }, { label: 'c' }]}
+      />
+    );
+
+    const navNode = findDOMNode(nav);
+    expect(navNode.style.background).toBe(containerStyle.background);
+    expect(navNode.className).toContain(containerClassName);
+
+    const bottomNav = findRenderedDOMComponentWithClass(nav, 'md-bottom-navigation');
+    expect(bottomNav.className).toContain(className);
+    expect(bottomNav.style.display).toBe(style.display);
+  });
+
+  it('can be a controlled component', () => {
+    const props = {
+      active: 'A',
+      actions: [{
+        label: 'A',
+      }, {
+        label: 'B',
+      }, {
+        label: 'C',
+      }],
+      onChange: jest.fn(),
+    };
+
+    const nav = renderIntoDocument(<BottomNavigation {...props} />);
+
+    nav.handleNavChange('B');
+    expect(props.onChange.mock.calls.length).toBe(1);
+    expect(props.onChange.mock.calls[0][0]).toBe('B');
+  });
+
+  it('generates a BottomNav component for each action', () => {
+    let actions = [{
+      label: 'A',
+    }, {
+      label: 'B',
+    }, {
+      label: 'C',
+    }];
+
+    let nav = renderIntoDocument(<BottomNavigation actions={actions} />);
+    let navs = scryRenderedComponentsWithType(nav, BottomNav);
+
+    expect(navs.length).toBe(actions.length);
+
+    actions = actions.concat([{ label: 'D' }, { label: 'E' }]);
+    nav = renderIntoDocument(<BottomNavigation actions={actions} />);
+    navs = scryRenderedComponentsWithType(nav, BottomNav);
+
+    expect(navs.length).toBe(actions.length);
+  });
+
+  it('only displays the bottom navigation when visible', () => {
+    const actions = [{ label: 'A' }, { label: 'B' }, { label: 'C' }];
+    const nav = renderIntoDocument(<BottomNavigation actions={actions} />);
+
+    let navs = scryRenderedDOMComponentsWithTag(nav, 'footer');
+    expect(navs.length).toBe(1);
+
+    nav.setState({ visible: false });
+    jest.runAllTimers();
+
+    navs = scryRenderedDOMComponentsWithTag(nav, 'footer');
+    expect(navs.length).toBe(0);
+  });
+
+  it('updates the state when the onTouchStart is called', () => {
+    const actions = [{ label: 'A' }, { label: 'B' }, { label: 'C' }];
+    const nav = renderIntoDocument(<BottomNavigation actions={actions} />);
+
+    nav.handleTouchStart({ changedTouches: [{ pageY: 32 }]});
+    expect(nav.state.scrolling).toBe(true);
+    expect(nav.state.pageY).toBe(32);
+  });
+
+  it('updates the state when the onTouchMove is called only if already scrolling', () => {
+    const actions = [{ label: 'A' }, { label: 'B' }, { label: 'C' }];
+    const nav = renderIntoDocument(<BottomNavigation actions={actions} />);
+
+    const startEvent = { changedTouches: [{ pageY: 32 }] };
+    // scrolling downwards
+    const moveEvent = { changedTouches: [{ pageY: 20 }] };
+    expect(nav.state.visible).toBe(true);
+
+    nav.handleTouchMove(moveEvent);
+
+    expect(nav.state.visible).toBe(true);
+    expect(nav.state.scrolling).toBe(false);
+    expect(nav.state.pageY).toBe(null);
+
+    nav.handleTouchStart(startEvent);
+    expect(nav.state.visible).toBe(true);
+    expect(nav.state.scrolling).toBe(true);
+    expect(nav.state.pageY).toBe(32);
+
+    nav.handleTouchMove(moveEvent);
+    expect(nav.state.visible).toBe(false);
+    expect(nav.state.pageY).toBe(20);
+    expect(nav.state.scrolling).toBe(true);
+  });
+
+  it('resets the scrolling state onTouchEnd', () => {
+    const actions = [{ label: 'A' }, { label: 'B' }, { label: 'C' }];
+    const nav = renderIntoDocument(<BottomNavigation actions={actions} />);
+
+    nav.handleTouchStart({ changedTouches: [{ pageY: 32 }] });
+    nav.handleTouchEnd();
+    expect(nav.state.visible).toBe(true);
+    expect(nav.state.pageY).toBe(null);
+    expect(nav.state.scrolling).toBe(false);
+  });
+});